use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::{local_folder_source, local_search_index}; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::Json; use core_protocol::{ EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceRange, EvidenceReadRequest, EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse, EvidenceSearchResult, ResourceSourceMap, SourceMapBlock, SourceMapTextItem, EVIDENCE_LOCATOR_SCHEMA, }; use serde_json::Map; use serde_json::{json, Value}; use std::path::{Component, Path}; pub async fn search( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let response = search_payload(&state, &context, body).await?; let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok((StatusCode::OK, headers, Json(response))) } pub(crate) async fn search_payload( state: &AppState, context: &RequestContext, body: EvidenceSearchRequest, ) -> Result { let effective_workspace_id = resolve_effective_workspace_id(context, Some(body.scope.workspace_id.as_str()), true)? .expect("workspace_required 已确保存在"); let root_uri = body.scope.root_uri.trim().to_string(); if root_uri.is_empty() { return Err( WebError::bad_request_code("evidence_root_required", "证据搜索缺少 rootUri") .with_context(context), ); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( state, context, &root_uri, ) .map_err(|error| error.with_context(context))?; let page_id = body.scope.target_document_id.as_deref(); let evidence_owner_filter = if body.scope.include_resources || body.scope.include_ocr { None } else { page_id }; let query = body.query.trim().to_string(); if matches!(body.mode, EvidenceSearchMode::Graph) { if let Some(mut results) = local_search_index::query_evidence_graph_results( &root_path, &query, evidence_owner_filter, body.top_k, )? { enrich_citation_links(&mut results); return Ok(json!(EvidenceSearchResponse { ok: true, results, diagnostics: None })); } } 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 diagnostics = Some(evidence_search_diagnostics( &query, &results, "evidence_sqlite", )); let response = EvidenceSearchResponse { ok: true, results, diagnostics, }; return Ok(json!(response)); } } let search = local_search_index::query_local_search_index( &root_path, &root_uri, &effective_workspace_id, &query, page_id, body.top_k.max(1), false, false, body.scope.include_ocr, )?; let response = EvidenceSearchResponse { ok: true, results: { let mut results = evidence_results_from_local_search( &search, &root_path, &root_uri, body.mode, &query, ); enrich_citation_links(&mut results); results }, diagnostics: None, }; Ok(json!(response)) } fn enrich_sqlite_evidence_results( results: &mut [EvidenceSearchResult], root_path: &Path, query: &str, ) { for result in results { let Some(source_map_path) = result.source.source_map_path.clone() else { continue; }; let Some(hit) = read_source_map_hit(root_path, &source_map_path, query) else { continue; }; if result.source.page.is_none() { result.source.page = hit.page; } if result.source.bbox.is_none() { result.source.bbox = hit.bbox.clone(); } if result.source.block_id.is_none() { result.source.block_id = hit.block_id.clone(); } if result.source.char_range.is_none() { result.source.char_range = hit.char_range.clone(); } if let Some(params) = result.source.open_action.params.as_object_mut() { if let Some(page) = result.source.page { params.insert("page".into(), json!(page)); } if let Some(bbox) = &result.source.bbox { params.insert("bbox".into(), json!(bbox)); } if let Some(block_id) = &result.source.block_id { params.insert("blockId".into(), json!(block_id)); } if let Some(char_range) = &result.source.char_range { params.insert("charRange".into(), json!(char_range)); } params.insert("query".into(), json!(query)); params.insert("sourceMapPath".into(), json!(source_map_path)); } } } fn enrich_citation_links(results: &mut [EvidenceSearchResult]) { for result in results { let citation_url = citation_url_for_locator(&result.source); result.source.open_action.url = citation_url.clone(); result.citation_label = Some(citation_label_for_locator(&result.source)); result.citation_markdown = Some(citation_markdown_for_locator(&result.source)); result.citation_url = Some(citation_url); } } 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); format!( "[{}]({})", markdown_link_label_escape(&label), url.replace(')', "%29") ) } fn citation_label_for_locator(locator: &EvidenceLocator) -> String { let source_path = locator .resource_path .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(locator.owner_document_path.as_str()); let name = source_path .rsplit('/') .find(|part| !part.trim().is_empty()) .unwrap_or(source_path) .trim(); let mut parts = vec![if name.is_empty() { "证据".to_string() } else { name.to_string() }]; if let Some(page) = locator.page { parts.push(format!("p.{page}")); } if let Some(section) = locator .section_path .last() .filter(|value| !value.trim().is_empty()) { parts.push(section.to_string()); } parts.join(" · ") } fn citation_url_for_locator(locator: &EvidenceLocator) -> String { let owner_document_id = locator.owner_document_id.trim(); let mut url = if owner_document_id.is_empty() { let existing = locator.open_action.url.trim(); if existing.is_empty() { "/".to_string() } else { existing.to_string() } } else { format!("/documents/{owner_document_id}") }; append_query_param(&mut url, "sourceKind", "local_folder"); append_query_param(&mut url, "rootUri", locator.root_uri.trim()); if let Some(resource_path) = locator .resource_path .as_deref() .filter(|value| !value.trim().is_empty()) { append_query_param( &mut url, "resourceTab", &format!( "resource:file:{}:{}", locator.root_uri.trim(), resource_path ), ); append_query_param(&mut url, "resourcePath", resource_path); } if let Some(page) = locator.page { append_query_param(&mut url, "page", &page.to_string()); } if let Some(bbox) = &locator.bbox { append_query_param( &mut url, "bbox", &format!("{},{},{},{}", bbox.x0, bbox.y0, bbox.x1, bbox.y1), ); } if let Some(block_id) = locator.block_id.as_deref() { append_query_param(&mut url, "blockId", block_id); } if let Some(source_map_path) = locator.source_map_path.as_deref() { append_query_param(&mut url, "sourceMapPath", source_map_path); } if let Some(line_range) = &locator.line_range { append_query_param( &mut url, "lineRange", &format!("{}-{}", line_range.start, line_range.end), ); } if let Some(char_range) = &locator.char_range { append_query_param( &mut url, "charRange", &format!("{}-{}", char_range.start, char_range.end), ); } url } fn append_query_param(url: &mut String, key: &str, value: &str) { let value = value.trim(); if value.is_empty() { return; } let separator = if url.contains('?') { '&' } else { '?' }; url.push(separator); url.push_str(&encode_query_component(key)); url.push('='); url.push_str(&encode_query_component(value)); } fn encode_query_component(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(*byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded } fn markdown_link_label_escape(value: &str) -> String { value .replace('\\', "\\\\") .replace('[', "\\[") .replace(']', "\\]") } pub async fn read( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let response = read_payload(&state, &context, body).await?; let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok((StatusCode::OK, headers, Json(response))) } pub(crate) async fn read_payload( state: &AppState, context: &RequestContext, body: EvidenceReadRequest, ) -> Result { let root_uri = body.locator.root_uri.trim().to_string(); if root_uri.is_empty() { return Err( WebError::bad_request_code("evidence_root_required", "证据读回缺少 rootUri") .with_context(context), ); } let root_path = local_folder_source::ensure_local_workspace_read_access_with_state( state, context, &root_uri, ) .map_err(|error| error.with_context(context))?; if let Some(mut results) = read_source_map_context( &root_path, &body.locator, body.context.before_blocks, body.context.after_blocks, ) { enrich_citation_links(&mut results); let quote = results .iter() .find(|item| locator_matches(&item.source, &body.locator)) .or_else(|| results.first()) .map(|item| item.quote.clone()) .unwrap_or_default(); let section_path = results .iter() .find(|item| !item.source.section_path.is_empty()) .map(|item| item.source.section_path.clone()) .unwrap_or_default(); let response = json!({ "ok": true, "locator": body.locator, "quote": quote, "sectionPath": section_path, "citationUrl": citation_url_for_locator(&body.locator), "citationLabel": citation_label_for_locator(&body.locator), "citationMarkdown": citation_markdown_for_locator(&body.locator), "contextBlocks": results, }); return Ok(response); } if let Some(mut results) = local_search_index::read_evidence_sqlite_context( &root_path, &body.locator, body.context.before_blocks, body.context.after_blocks, )? { if !results.is_empty() { enrich_citation_links(&mut results); let quote = results .iter() .find(|item| locator_matches(&item.source, &body.locator)) .or_else(|| results.first()) .map(|item| item.quote.clone()) .unwrap_or_default(); let response = json!({ "ok": true, "locator": body.locator, "quote": quote, "citationUrl": citation_url_for_locator(&body.locator), "citationLabel": citation_label_for_locator(&body.locator), "citationMarkdown": citation_markdown_for_locator(&body.locator), "contextBlocks": results, }); return Ok(response); } } let page_id = Some(body.locator.owner_document_id.as_str()); let query = body .locator .block_id .clone() .or_else(|| body.locator.page.map(|page| page.to_string())) .unwrap_or_default(); let workspace_id = local_folder_source::local_workspace_id_from_root_uri(&root_uri) .unwrap_or_else(|_| "default".to_string()); let search = local_search_index::query_local_search_index( &root_path, &root_uri, &workspace_id, &query, page_id, (body.context.before_blocks + body.context.after_blocks + 1).max(1), false, false, true, )?; let mut results = evidence_results_from_local_search( &search, &root_path, &root_uri, EvidenceSearchMode::Tree, &query, ); enrich_citation_links(&mut results); let quote = results .first() .map(|item| item.quote.clone()) .unwrap_or_default(); let response = json!({ "ok": true, "locator": body.locator, "quote": quote, "citationUrl": citation_url_for_locator(&body.locator), "citationLabel": citation_label_for_locator(&body.locator), "citationMarkdown": citation_markdown_for_locator(&body.locator), "contextBlocks": results, }); Ok(response) } fn read_source_map_context( root_path: &Path, locator: &EvidenceLocator, before_blocks: u32, after_blocks: u32, ) -> Option> { let source_map_path = locator.source_map_path.as_deref()?; if !is_safe_relative_path(source_map_path) { return None; } let source_map = std::fs::read_to_string(root_path.join(source_map_path)).ok()?; let source_map: ResourceSourceMap = serde_json::from_str(&source_map).ok()?; let mut blocks = Vec::new(); for page in &source_map.pages { for block in &page.blocks { let section_path = source_map_section_path_for_block(&source_map, &block.id); blocks.push((page.page, block, section_path)); } } if blocks.is_empty() { return None; } let anchor_index = blocks.iter().position(|(page, block, _)| { locator .block_id .as_deref() .is_some_and(|block_id| block_id == block.id) || locator .page .is_some_and(|locator_page| locator_page == *page) && locator .char_range .as_ref() .zip(block.char_range.as_ref()) .is_some_and(|(left, right)| left.start == right.start && left.end == right.end) })?; let anchor_section = blocks[anchor_index].2.clone(); let start = anchor_index.saturating_sub(before_blocks as usize); let end = (anchor_index + after_blocks as usize + 1).min(blocks.len()); let mut selected = blocks[start..end] .iter() .enumerate() .map(|(offset, (page, block, section_path))| { source_map_block_result( locator, source_map_path, *page, block, section_path.clone(), if start + offset == anchor_index { 1.0 } else { 0.8 }, ) }) .collect::>(); if !anchor_section.is_empty() { for (page, block, section_path) in blocks.iter().filter(|(_, _, path)| *path == anchor_section) { if selected .iter() .any(|item| item.source.block_id.as_deref() == Some(block.id.as_str())) { continue; } selected.push(source_map_block_result( locator, source_map_path, *page, block, section_path.clone(), 0.7, )); } } Some(selected) } fn source_map_section_path_for_block( source_map: &ResourceSourceMap, block_id: &str, ) -> Vec { source_map .sections .iter() .find(|section| section.block_ids.iter().any(|id| id == block_id)) .map(|section| section.path.clone()) .unwrap_or_default() } fn source_map_block_result( locator: &EvidenceLocator, source_map_path: &str, page: u32, block: &SourceMapBlock, section_path: Vec, score: f64, ) -> EvidenceSearchResult { let mut source = locator.clone(); source.page = Some(page); source.bbox = block.bbox.clone(); source.block_id = Some(block.id.clone()); source.char_range = block.char_range.clone(); source.section_path = section_path; source.source_map_path = Some(source_map_path.to_string()); if let Some(params) = source.open_action.params.as_object_mut() { params.insert("page".into(), json!(page)); if let Some(bbox) = &source.bbox { params.insert("bbox".into(), json!(bbox)); } params.insert("blockId".into(), json!(block.id)); if let Some(char_range) = &source.char_range { params.insert("charRange".into(), json!(char_range)); } params.insert("sourceMapPath".into(), json!(source_map_path)); } EvidenceSearchResult { evidence_id: format!("{}#{}", locator.owner_document_id, block.id), quote: block.text.clone(), score, source, match_info: None, citation_url: None, citation_label: None, citation_markdown: None, } } fn locator_matches(left: &EvidenceLocator, right: &EvidenceLocator) -> bool { right .block_id .as_deref() .is_some_and(|block_id| left.block_id.as_deref() == Some(block_id)) || left.owner_document_id == right.owner_document_id && left.resource_path == right.resource_path && left.source_map_path == right.source_map_path } pub async fn open( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let response = open_payload(&state, &context, body).await?; let mut headers = HeaderMap::new(); headers.insert("content-type", HeaderValue::from_static("application/json")); Ok((StatusCode::OK, headers, Json(response))) } pub(crate) async fn open_payload( state: &AppState, context: &RequestContext, body: EvidenceOpenRequest, ) -> Result { let mut locator = body.locator; let citation_url = citation_url_for_locator(&locator); locator.open_action.url = citation_url.clone(); let root_uri = locator.root_uri.trim().to_string(); if root_uri.is_empty() { return Err( WebError::bad_request_code("evidence_root_required", "证据打开缺少 rootUri") .with_context(context), ); } let _ = local_folder_source::ensure_local_workspace_read_access_with_state( state, context, &root_uri, ) .map_err(|error| error.with_context(context))?; let response = json!({ "ok": true, "locator": locator.clone(), "openAction": locator.open_action, "citationUrl": citation_url, "citationLabel": citation_label_for_locator(&locator), "citationMarkdown": citation_markdown_for_locator(&locator), }); Ok(response) } pub(crate) fn evidence_results_from_local_search( payload: &Value, root_path: &Path, root_uri: &str, mode: EvidenceSearchMode, query: &str, ) -> Vec { let results = payload .get("results") .and_then(Value::as_array) .cloned() .unwrap_or_default(); results .into_iter() .enumerate() .map(|(index, result)| { let owner_document_id = result .get("documentId") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let owner_document_path = result .get("path") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let source_map_path = result .get("sourceMapPath") .and_then(Value::as_str) .map(str::to_string) .or_else(|| { result .get("ocrEvidence") .and_then(|value| value.get("ocrRootRelativePath")) .and_then(Value::as_str) .and_then(path_source_map_path) }); let source_map_hit = source_map_path .as_deref() .and_then(|path| read_source_map_hit(root_path, path, query)); let resource_path = result .get("ocrEvidence") .and_then(|value| value.get("sourceRootRelativePath")) .and_then(Value::as_str) .map(str::to_string) .or_else(|| { if owner_document_path.is_empty() { None } else { Some(owner_document_path.clone()) } }); let resource_kind = infer_resource_kind( result .get("resourceType") .and_then(Value::as_str) .unwrap_or("markdown"), resource_path.as_deref(), ); let public_path = result .get("publicPath") .and_then(Value::as_str) .unwrap_or("/") .to_string(); let page = result .get("page") .and_then(Value::as_u64) .and_then(|page| u32::try_from(page).ok()) .or_else(|| source_map_hit.as_ref().and_then(|hit| hit.page)); let bbox = result .get("bbox") .and_then(Value::as_array) .and_then(|bbox| { if bbox.len() != 4 { return None; } Some(EvidenceBBox { x0: bbox[0].as_f64()?, y0: bbox[1].as_f64()?, x1: bbox[2].as_f64()?, y1: bbox[3].as_f64()?, }) }) .or_else(|| source_map_hit.as_ref().and_then(|hit| hit.bbox.clone())); let block_id = result .get("blockId") .and_then(Value::as_str) .map(str::to_string) .or_else(|| { result .get("ocrEvidence") .and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone())) }) .or_else(|| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone())); let line_range = result.get("lineRange").and_then(evidence_range_from_value); let char_range = source_map_hit .as_ref() .and_then(|hit| hit.char_range.clone()) .or_else(|| result.get("charRange").and_then(evidence_range_from_value)); let open_action_params = evidence_open_action_params( &result, query, &mode, page, bbox.clone(), block_id.clone(), line_range.clone(), char_range.clone(), source_map_path.clone(), ); let locator = EvidenceLocator { schema: EVIDENCE_LOCATOR_SCHEMA.into(), root_uri: root_uri.to_string(), owner_document_id, owner_document_path, resource_path, resource_kind, page, bbox: bbox.clone(), section_path: result .get("sectionPath") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .unwrap_or_default(), line_range, char_range, block_id, source_map_path: source_map_path.clone(), open_action: EvidenceOpenAction { action_type: "mnote.open_resource_locator".into(), url: public_path.clone(), params: Value::Object(open_action_params), }, }; EvidenceSearchResult { evidence_id: result .get("id") .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| format!("evidence_{index}")), quote: result .get("snippet") .and_then(Value::as_str) .unwrap_or_default() .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, } }) .collect() } fn evidence_open_action_params( result: &Value, query: &str, mode: &EvidenceSearchMode, page: Option, bbox: Option, block_id: Option, line_range: Option, char_range: Option, source_map_path: Option, ) -> Map { let mut params = Map::new(); params.insert("query".into(), json!(query)); params.insert("mode".into(), json!(evidence_mode_label(mode))); if let Some(resource_path) = result .get("ocrEvidence") .and_then(|value| value.get("sourceRootRelativePath")) .and_then(Value::as_str) { params.insert("resourcePath".into(), json!(resource_path)); } if let Some(page) = page { params.insert("page".into(), json!(page)); } if let Some(bbox) = bbox { params.insert("bbox".into(), json!(bbox)); } if let Some(block_id) = block_id { params.insert("blockId".into(), json!(block_id)); } if let Some(line_range) = line_range { params.insert("lineRange".into(), json!(line_range)); } if let Some(char_range) = char_range { params.insert("charRange".into(), json!(char_range)); } if let Some(source_map_path) = source_map_path { params.insert("sourceMapPath".into(), json!(source_map_path)); } params } fn evidence_range_from_value(value: &Value) -> Option { if let Some(map) = value.as_object() { let start = map.get("start")?.as_u64()?; let end = map.get("end")?.as_u64()?; return Some(EvidenceRange { start, end }); } let text = value.as_str()?.trim(); let (start, end) = text.split_once('-')?; Some(EvidenceRange { start: start.trim().parse().ok()?, end: end.trim().parse().ok()?, }) } fn infer_resource_kind(resource_type: &str, resource_path: Option<&str>) -> EvidenceResourceKind { let extension = resource_path .and_then(|path| Path::new(path).extension()) .and_then(|value| value.to_str()); match extension { Some("pdf") => EvidenceResourceKind::Pdf, Some("png") | Some("jpg") | Some("jpeg") | Some("webp") => EvidenceResourceKind::Image, Some("doc") | Some("docx") | Some("xls") | Some("xlsx") | Some("ppt") | Some("pptx") => { EvidenceResourceKind::Office } Some("md") => EvidenceResourceKind::Markdown, Some("mm") | Some("mindmap") => EvidenceResourceKind::Mindmap, _ => match resource_type { "markdown" => EvidenceResourceKind::Markdown, "mindmap" => EvidenceResourceKind::Mindmap, "office" => EvidenceResourceKind::Office, "pdf" => EvidenceResourceKind::Pdf, "image" => EvidenceResourceKind::Image, "raw_file" => EvidenceResourceKind::RawFile, _ => EvidenceResourceKind::RawFile, }, } } fn path_source_map_path(path: &str) -> Option { if let Some(stripped) = path.strip_suffix(".ocr.md") { return Some(format!("{stripped}.source-map.json")); } if let Some(stripped) = path.strip_suffix(".parse.md") { return Some(format!("{stripped}.source-map.json")); } None } #[derive(Debug, Clone)] struct SourceMapHit { page: Option, block_id: Option, bbox: Option, char_range: Option, } fn read_source_map_hit( root_path: &Path, source_map_path: &str, query: &str, ) -> Option { if !is_safe_relative_path(source_map_path) { return None; } let source_map = std::fs::read_to_string(root_path.join(source_map_path)).ok()?; let source_map: ResourceSourceMap = serde_json::from_str(&source_map).ok()?; find_source_map_hit(&source_map, query) } fn find_source_map_hit(source_map: &ResourceSourceMap, query: &str) -> Option { let normalized_query = query.trim().to_lowercase(); for page in &source_map.pages { if let Some(block) = find_block_hit(&page.blocks, &normalized_query) { return Some(SourceMapHit { page: Some(page.page), block_id: Some(block.id.clone()), bbox: block.bbox.clone(), char_range: block.char_range.clone(), }); } if let Some(item) = find_text_item_hit(&page.text_items, &normalized_query) { return Some(SourceMapHit { page: Some(page.page), block_id: Some(item.id.clone()), bbox: item.bbox.clone(), char_range: item.char_range.clone(), }); } } None } fn find_block_hit<'a>( blocks: &'a [SourceMapBlock], normalized_query: &str, ) -> Option<&'a SourceMapBlock> { blocks .iter() .find(|block| source_map_text_matches(&block.text, normalized_query)) .or_else(|| blocks.first()) } fn find_text_item_hit<'a>( items: &'a [SourceMapTextItem], normalized_query: &str, ) -> Option<&'a SourceMapTextItem> { items .iter() .find(|item| source_map_text_matches(&item.text, normalized_query)) .or_else(|| items.first()) } fn source_map_text_matches(text: &str, normalized_query: &str) -> bool { if normalized_query.is_empty() { return true; } text.to_lowercase().contains(normalized_query) } fn is_safe_relative_path(path: &str) -> bool { let path = Path::new(path); !path.is_absolute() && path .components() .all(|component| matches!(component, Component::Normal(_))) } fn evidence_mode_label(mode: &EvidenceSearchMode) -> &'static str { match mode { EvidenceSearchMode::Keyword => "keyword", EvidenceSearchMode::Tree => "tree", EvidenceSearchMode::Hybrid => "hybrid", EvidenceSearchMode::Graph => "graph", } } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use core_protocol::{EvidenceOpenAction, RESOURCE_SOURCE_MAP_SCHEMA}; use std::fs; use tower::util::ServiceExt; #[test] fn evidence_results_from_local_search_uses_locator_contract() { let payload = json!({ "results": [{ "id": "doc-1", "documentId": "local-md:docs~2FPage.md", "title": "Page", "path": "docs/Page.md", "resourceType": "markdown", "sourceKind": "local_folder", "rootUri": "file:///workspace", "snippet": "hello world", "publicPath": "/documents/local-md:docs~2FPage.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fworkspace" }] }); let results = evidence_results_from_local_search( &payload, Path::new("/workspace"), "file:///workspace", EvidenceSearchMode::Hybrid, "hello", ); assert_eq!(results.len(), 1); assert_eq!(results[0].quote, "hello world"); assert_eq!(results[0].source.schema, EVIDENCE_LOCATOR_SCHEMA); assert_eq!( results[0].source.resource_kind, EvidenceResourceKind::Markdown ); assert_eq!( results[0].source.open_action, EvidenceOpenAction { action_type: "mnote.open_resource_locator".into(), url: "/documents/local-md:docs~2FPage.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fworkspace".into(), params: json!({"query":"hello","mode":"hybrid"}) } ); } #[tokio::test] async fn evidence_search_route_prefers_sqlite_index() { let root = std::env::temp_dir().join(format!( "mnote-evidence-route-sqlite-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time") .as_nanos() )); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::write( root.join(".mnote").join("workspace.json"), r#"{"workspaceId":"local-ws-evidence-route","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#, ) .expect("manifest"); fs::write( root.join("README.md"), "# Evidence Route\nSqliteRouteToken should come from evidence.sqlite.\n", ) .expect("readme"); let root_uri = format!("file://{}", root.display()); local_search_index::write_local_index_settings( &root, &[String::from(".")], None, None, None, None, ) .expect("settings"); local_search_index::refresh_local_search_index(&root, &root_uri, "local-ws-evidence-route") .expect("refresh"); fs::write( root.join(".mnote").join("index").join("search-index.json"), serde_json::to_string_pretty(&json!({ "version": 1, "builtAt": 1, "rootUri": root_uri, "workspaceId": "local-ws-evidence-route", "documents": [], "resources": [] })) .expect("stale search index"), ) .expect("overwrite search index"); let app = build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/evidence/search") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_test") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "query": "SqliteRouteToken", "scope": { "workspaceId": "local-ws-evidence-route", "rootUri": root_uri, "targetDocumentId": "local-md:missing.md", "includeResources": true, "includeOcr": true }, "mode": "hybrid", "topK": 5 }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let first = payload["results"] .as_array() .and_then(|items| items.first()) .expect("sqlite evidence result"); assert_eq!( first["source"]["ownerDocumentPath"].as_str(), Some("README.md") ); assert_eq!( first["source"]["schema"].as_str(), Some("mnote.evidence_locator.v1") ); fs::remove_dir_all(&root).ok(); } #[test] fn evidence_results_from_ocr_search_uses_source_map_locator() { let root = std::env::temp_dir().join(format!( "mnote-evidence-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time") .as_nanos() )); fs::create_dir_all(root.join("docs/Page.ocr")).expect("source map dir"); fs::write( root.join("docs/Page.ocr/photo.png.source-map.json"), serde_json::to_string_pretty(&json!({ "schema": RESOURCE_SOURCE_MAP_SCHEMA, "provider": "mineru", "ownerDocumentPath": "docs/Page.md", "sourceRootRelativePath": "docs/Page.assets/photo.png", "sourceHash": "size:3:mtime:1", "pageCount": 1, "pages": [{ "page": 1, "textItems": [], "blocks": [{ "id": "p1_b1", "blockType": "paragraph", "text": "OCR-only-token 识别正文", "bbox": { "x0": 10.0, "y0": 20.0, "x1": 110.0, "y1": 40.0 }, "charRange": { "start": 0, "end": 18 } }] }], "sections": [] })) .expect("source map json"), ) .expect("write source map"); let payload = json!({ "results": [{ "id": "local-md:docs~2FPage.md#ocr:docs/Page.assets/photo.png", "documentId": "local-md:docs~2FPage.md", "title": "Page", "path": "docs/Page.md", "resourceType": "markdown", "snippet": "OCR-only-token 识别正文", "ocrEvidence": { "sourceRootRelativePath": "docs/Page.assets/photo.png", "ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md", "provider": "mineru", "status": "done" }, "publicPath": "/documents/local-md:docs~2FPage.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fworkspace" }] }); let mut results = evidence_results_from_local_search( &payload, &root, "file:///workspace", EvidenceSearchMode::Hybrid, "OCR-only-token", ); enrich_citation_links(&mut results); fs::remove_dir_all(&root).ok(); assert_eq!(results.len(), 1); let locator = &results[0].source; assert_eq!(locator.resource_kind, EvidenceResourceKind::Image); assert_eq!( locator.resource_path.as_deref(), Some("docs/Page.assets/photo.png") ); assert_eq!( locator.source_map_path.as_deref(), Some("docs/Page.ocr/photo.png.source-map.json") ); assert_eq!(locator.page, Some(1)); assert_eq!(locator.block_id.as_deref(), Some("p1_b1")); assert_eq!(locator.bbox.as_ref().map(|bbox| bbox.x0), Some(10.0)); assert_eq!(locator.char_range.as_ref().map(|range| range.end), Some(18)); assert_eq!( locator.open_action.params["sourceMapPath"].as_str(), Some("docs/Page.ocr/photo.png.source-map.json") ); let citation_url = results[0].citation_url.as_deref().unwrap_or_default(); assert!(citation_url.starts_with("/documents/local-md:docs~2FPage.md?")); assert!(citation_url.contains("resourceTab=resource%3Afile%3Afile%3A%2F%2F%2Fworkspace%3Adocs%2FPage.assets%2Fphoto.png")); assert!(citation_url.contains("page=1")); assert!(citation_url.contains("blockId=p1_b1")); assert!(results[0] .citation_markdown .as_deref() .unwrap_or_default() .contains("photo.png")); } #[test] fn sqlite_evidence_results_are_enriched_from_source_map() { let root = std::env::temp_dir().join(format!( "mnote-evidence-sqlite-enrich-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time") .as_nanos() )); fs::create_dir_all(root.join("docs/Page.ocr")).expect("source map dir"); fs::write( root.join("docs/Page.ocr/spec.pdf.source-map.json"), serde_json::to_string_pretty(&json!({ "schema": RESOURCE_SOURCE_MAP_SCHEMA, "provider": "mineru", "ownerDocumentPath": "docs/Page.md", "sourceRootRelativePath": "docs/Page.assets/spec.pdf", "sourceHash": "size:3:mtime:1", "pageCount": 2, "pages": [{ "page": 2, "textItems": [], "blocks": [{ "id": "p2_b7", "blockType": "paragraph", "text": "NeedleToken 原文定位", "bbox": { "x0": 72.0, "y0": 220.0, "x1": 510.0, "y1": 268.0 }, "charRange": { "start": 5, "end": 16 } }] }], "sections": [] })) .expect("source map json"), ) .expect("write source map"); let mut locator = EvidenceLocator::new( "file:///workspace", "local-md:docs~2FPage.md", "docs/Page.md", EvidenceResourceKind::Pdf, EvidenceOpenAction { action_type: "mnote.open_resource_locator".into(), url: "/documents/local-md:docs~2FPage.md".into(), params: json!({"resourcePath":"docs/Page.assets/spec.pdf"}), }, ); locator.resource_path = Some("docs/Page.assets/spec.pdf".into()); locator.source_map_path = Some("docs/Page.ocr/spec.pdf.source-map.json".into()); let mut results = vec![EvidenceSearchResult { evidence_id: "ev_spec".into(), quote: "NeedleToken 原文定位".into(), score: 1.0, source: locator, match_info: None, citation_url: None, citation_label: None, citation_markdown: None, }]; enrich_sqlite_evidence_results(&mut results, &root, "NeedleToken"); enrich_citation_links(&mut results); fs::remove_dir_all(&root).ok(); let locator = &results[0].source; assert_eq!(locator.page, Some(2)); assert_eq!(locator.block_id.as_deref(), Some("p2_b7")); assert_eq!(locator.bbox.as_ref().map(|bbox| bbox.y0), Some(220.0)); assert_eq!( locator.char_range.as_ref().map(|range| range.start), Some(5) ); assert_eq!(locator.open_action.params["page"].as_u64(), Some(2)); assert_eq!( locator.open_action.params["blockId"].as_str(), Some("p2_b7") ); assert!(results[0] .citation_url .as_deref() .unwrap_or_default() .contains("sourceMapPath=docs%2FPage.ocr%2Fspec.pdf.source-map.json")); assert!(results[0] .citation_markdown .as_deref() .unwrap_or_default() .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!( "mnote-evidence-read-source-map-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time") .as_nanos() )); fs::create_dir_all(root.join("docs/Page.ocr")).expect("source map dir"); fs::write( root.join("docs/Page.ocr/spec.pdf.source-map.json"), serde_json::to_string_pretty(&json!({ "schema": RESOURCE_SOURCE_MAP_SCHEMA, "provider": "liteparse", "ownerDocumentPath": "docs/Page.md", "sourceRootRelativePath": "docs/Page.assets/spec.pdf", "sourceHash": "sha256:demo", "pageCount": 1, "pages": [{ "page": 1, "textItems": [], "blocks": [ {"id": "p1_b1", "blockType": "paragraph", "text": "同节前文", "charRange": {"start": 0, "end": 4}}, {"id": "p1_b2", "blockType": "paragraph", "text": "AnchorToken 命中原文", "bbox": {"x0": 1.0, "y0": 2.0, "x1": 3.0, "y1": 4.0}, "charRange": {"start": 5, "end": 16}}, {"id": "p1_b3", "blockType": "paragraph", "text": "同节后文", "charRange": {"start": 17, "end": 21}} ] }], "sections": [{ "id": "sec_1", "title": "解除条件", "path": ["合同", "解除条件"], "pageStart": 1, "pageEnd": 1, "blockIds": ["p1_b1", "p1_b2", "p1_b3"] }] })) .expect("source map json"), ) .expect("write source map"); let mut locator = EvidenceLocator::new( "file:///workspace", "local-md:docs~2FPage.md", "docs/Page.md", EvidenceResourceKind::Pdf, EvidenceOpenAction { action_type: "mnote.open_resource_locator".into(), url: "/documents/local-md:docs~2FPage.md".into(), params: json!({"resourcePath":"docs/Page.assets/spec.pdf"}), }, ); locator.resource_path = Some("docs/Page.assets/spec.pdf".into()); locator.source_map_path = Some("docs/Page.ocr/spec.pdf.source-map.json".into()); locator.block_id = Some("p1_b2".into()); let results = read_source_map_context(&root, &locator, 0, 0).expect("source map context"); fs::remove_dir_all(&root).ok(); assert!(results .iter() .any(|item| item.quote == "AnchorToken 命中原文")); assert!(results.iter().any(|item| item.quote == "同节前文")); assert!(results.iter().any(|item| item.quote == "同节后文")); let anchor = results .iter() .find(|item| item.source.block_id.as_deref() == Some("p1_b2")) .expect("anchor"); assert_eq!(anchor.source.section_path, vec!["合同", "解除条件"]); assert_eq!(anchor.source.bbox.as_ref().map(|bbox| bbox.y0), Some(2.0)); } }