- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes - fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug - keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable Verification: - node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js - node scripts/task-dev-hot-plan-test.js - cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar - cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot - cargo build -p mnote-web --manifest-path rust/Cargo.toml - browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
1106 lines
40 KiB
Rust
1106 lines
40 KiB
Rust
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, 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 query = body.query.trim().to_string();
|
|
if matches!(body.mode, EvidenceSearchMode::Graph) {
|
|
if let Some(results) = local_search_index::query_evidence_graph_results(
|
|
&root_path, &query, page_id, body.top_k,
|
|
)? {
|
|
return Ok(json!(EvidenceSearchResponse { ok: true, results }));
|
|
}
|
|
}
|
|
if let Some(mut results) =
|
|
local_search_index::query_evidence_sqlite_results(&root_path, &query, page_id, body.top_k)?
|
|
{
|
|
enrich_sqlite_evidence_results(&mut results, &root_path, &query);
|
|
if !results.is_empty() || !query.is_empty() {
|
|
let response = EvidenceSearchResponse { ok: true, results };
|
|
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: evidence_results_from_local_search(
|
|
&search, &root_path, &root_uri, body.mode, &query,
|
|
),
|
|
};
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
|
|
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(results) = read_source_map_context(
|
|
&root_path,
|
|
&body.locator,
|
|
body.context.before_blocks,
|
|
body.context.after_blocks,
|
|
) {
|
|
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,
|
|
"contextBlocks": results,
|
|
});
|
|
return Ok(response);
|
|
}
|
|
if let Some(results) = local_search_index::read_evidence_sqlite_context(
|
|
&root_path,
|
|
&body.locator,
|
|
body.context.before_blocks,
|
|
body.context.after_blocks,
|
|
)? {
|
|
if !results.is_empty() {
|
|
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,
|
|
"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 results = evidence_results_from_local_search(
|
|
&search,
|
|
&root_path,
|
|
&root_uri,
|
|
EvidenceSearchMode::Tree,
|
|
&query,
|
|
);
|
|
let quote = results
|
|
.first()
|
|
.map(|item| item.quote.clone())
|
|
.unwrap_or_default();
|
|
let response = json!({
|
|
"ok": true,
|
|
"locator": body.locator,
|
|
"quote": quote,
|
|
"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,
|
|
}
|
|
}
|
|
|
|
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 locator = body.locator;
|
|
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,
|
|
});
|
|
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("ocrEvidence")
|
|
.and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()))
|
|
.or_else(|| result.get("id").and_then(Value::as_str).map(str::to_string))
|
|
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()));
|
|
let char_range = source_map_hit
|
|
.as_ref()
|
|
.and_then(|hit| hit.char_range.clone());
|
|
let open_action_params = evidence_open_action_params(
|
|
&result,
|
|
query,
|
|
&mode,
|
|
page,
|
|
bbox.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: None,
|
|
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,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn evidence_open_action_params(
|
|
result: &Value,
|
|
query: &str,
|
|
mode: &EvidenceSearchMode,
|
|
page: Option<u32>,
|
|
bbox: Option<EvidenceBBox>,
|
|
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(source_map_path) = source_map_path {
|
|
params.insert("sourceMapPath".into(), json!(source_map_path));
|
|
}
|
|
params
|
|
}
|
|
|
|
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::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,
|
|
"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 results = evidence_results_from_local_search(
|
|
&payload,
|
|
&root,
|
|
"file:///workspace",
|
|
EvidenceSearchMode::Hybrid,
|
|
"OCR-only-token",
|
|
);
|
|
|
|
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")
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
}];
|
|
|
|
enrich_sqlite_evidence_results(&mut results, &root, "NeedleToken");
|
|
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")
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|