Files
mnote/rust/crates/mnote-web/src/routes/evidence.rs
T

1495 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceSearchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value, WebError> {
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<String> {
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<String> {
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::<String>());
}
}
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceReadRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value, WebError> {
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<Vec<EvidenceSearchResult>> {
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::<Vec<_>>();
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<String> {
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<String>,
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceOpenRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value, WebError> {
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<EvidenceSearchResult> {
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::<Vec<_>>()
})
.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<u32>,
bbox: Option<EvidenceBBox>,
block_id: Option<String>,
line_range: Option<EvidenceRange>,
char_range: Option<EvidenceRange>,
source_map_path: Option<String>,
) -> Map<String, Value> {
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<EvidenceRange> {
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<String> {
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<u32>,
block_id: Option<String>,
bbox: Option<EvidenceBBox>,
char_range: Option<core_protocol::EvidenceRange>,
}
fn read_source_map_hit(
root_path: &Path,
source_map_path: &str,
query: &str,
) -> Option<SourceMapHit> {
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<SourceMapHit> {
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));
}
}