收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
This commit is contained in:
@@ -3,6 +3,7 @@ use crate::routes::local_folder_source::encode_local_id_segment;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use crate::routes::local_ocr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -55,6 +56,7 @@ pub(crate) fn query_local_search_index(
|
||||
limit: u32,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
include_ocr: bool,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
@@ -90,6 +92,39 @@ pub(crate) fn query_local_search_index(
|
||||
}
|
||||
}
|
||||
}
|
||||
if include_ocr && results.len() < limit.max(1) as usize {
|
||||
for entry in local_ocr::ocr_index_entries(root_path)? {
|
||||
if let Some(page_id) = page_id {
|
||||
if entry.owner_document_id != page_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entry.status != "done" {
|
||||
continue;
|
||||
}
|
||||
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
|
||||
let markdown = match fs::read_to_string(&ocr_path) {
|
||||
Ok(markdown) => markdown,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
|
||||
continue;
|
||||
}
|
||||
let body = local_ocr::strip_ocr_frontmatter(&markdown);
|
||||
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
|
||||
continue;
|
||||
}
|
||||
results.push(local_search_ocr_projection(
|
||||
&entry,
|
||||
body,
|
||||
root_uri,
|
||||
&normalized_query,
|
||||
));
|
||||
if results.len() >= limit.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
@@ -100,7 +135,8 @@ pub(crate) fn query_local_search_index(
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len(),
|
||||
"resourceCount": index.resources.len()
|
||||
"resourceCount": index.resources.len(),
|
||||
"includeOcr": include_ocr
|
||||
},
|
||||
"recentChanges": recent_changes,
|
||||
"results": results
|
||||
@@ -404,6 +440,14 @@ fn collect_markdown_documents(
|
||||
continue;
|
||||
}
|
||||
if is_markdown_path(&path) {
|
||||
let relative_path = path
|
||||
.strip_prefix(root_path)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
documents.push(index_markdown_file(root_path, &path)?);
|
||||
} else if resource_type_from_path(&path).is_some() {
|
||||
resources.push(index_resource_file(root_path, &path)?);
|
||||
@@ -565,6 +609,34 @@ fn local_search_resource_matches(
|
||||
}
|
||||
}
|
||||
|
||||
fn local_search_ocr_matches(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
query: &str,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let haystack = if title_only {
|
||||
normalize_search_text(&format!(
|
||||
"{}\n{}",
|
||||
entry.source_root_relative_path, entry.ocr_root_relative_path
|
||||
))
|
||||
} else {
|
||||
normalize_search_text(&format!(
|
||||
"{}\n{}\n{}",
|
||||
entry.source_root_relative_path, entry.ocr_root_relative_path, body
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_search_document_projection(
|
||||
document: &LocalSearchDocument,
|
||||
root_uri: &str,
|
||||
@@ -608,6 +680,42 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
|
||||
})
|
||||
}
|
||||
|
||||
fn local_search_ocr_projection(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
root_uri: &str,
|
||||
query: &str,
|
||||
) -> Value {
|
||||
let title = Path::new(&entry.owner_document_path)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("OCR")
|
||||
.to_string();
|
||||
json!({
|
||||
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
|
||||
"documentId": entry.owner_document_id,
|
||||
"title": title,
|
||||
"path": entry.owner_document_path,
|
||||
"resourceType": "markdown",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"hasOcr": true,
|
||||
"snippet": ocr_search_snippet(body, query),
|
||||
"ocrEvidence": {
|
||||
"sourceRootRelativePath": entry.source_root_relative_path,
|
||||
"ocrRootRelativePath": entry.ocr_root_relative_path,
|
||||
"provider": entry.provider,
|
||||
"status": entry.status,
|
||||
},
|
||||
"updatedAt": entry.updated_at_ms,
|
||||
"publicPath": format!(
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}",
|
||||
entry.owner_document_id,
|
||||
encode_query_component(root_uri),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
@@ -764,6 +872,27 @@ fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
||||
document.raw_text.chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
if normalized_query.is_empty() {
|
||||
return normalized_body.chars().take(160).collect();
|
||||
}
|
||||
let lower = normalized_body.to_ascii_lowercase();
|
||||
let lower_query = normalized_query.to_ascii_lowercase();
|
||||
if let Some(byte_index) = lower.find(&lower_query) {
|
||||
let start = normalized_body[..byte_index]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(40)
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
normalized_body[start..].chars().take(160).collect()
|
||||
} else {
|
||||
normalized_body.chars().take(160).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
@@ -845,6 +974,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("projection");
|
||||
let results = projection["results"].as_array().expect("results");
|
||||
@@ -898,6 +1028,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("child search");
|
||||
let child_result = child_search["results"]
|
||||
@@ -929,6 +1060,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("mindmap projection");
|
||||
assert!(mindmap_projection["results"]
|
||||
@@ -953,6 +1085,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("office projection");
|
||||
assert!(office_projection["results"]
|
||||
@@ -1017,6 +1150,104 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
|
||||
let root = temp_root("mnote-local-search-ocr");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-ocr";
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n正文\n").expect("page");
|
||||
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
|
||||
)
|
||||
.expect("ocr markdown");
|
||||
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
|
||||
fs::write(
|
||||
root.join(".mnote").join("ocr-index.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"version": 1,
|
||||
"entries": {
|
||||
"docs/Page.assets/photo.png": {
|
||||
"jobId": "ocr_test",
|
||||
"ownerDocumentId": "local-md:docs~2FPage.md",
|
||||
"ownerDocumentPath": "docs/Page.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
|
||||
"provider": "mock",
|
||||
"modelVersion": "vlm",
|
||||
"status": "done",
|
||||
"sourceSize": 3,
|
||||
"sourceMtimeMs": 1,
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 1,
|
||||
"plainTextPreview": "OCR-only-token 识别正文"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("serialize index"),
|
||||
)
|
||||
.expect("ocr index");
|
||||
|
||||
let without_ocr = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OCR-only-token",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("without ocr");
|
||||
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
|
||||
|
||||
let with_ocr = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OCR-only-token",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("with ocr");
|
||||
let result = with_ocr["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("ocr result");
|
||||
assert_eq!(
|
||||
result["documentId"].as_str(),
|
||||
Some("local-md:docs~2FPage.md")
|
||||
);
|
||||
assert_eq!(result["hasOcr"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/Page.assets/photo.png")
|
||||
);
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
|
||||
Some("docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read search index")
|
||||
.expect("search index");
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_query_reads_existing_index_without_rebuilding() {
|
||||
let root = temp_root("mnote-local-search-query-cache");
|
||||
@@ -1038,6 +1269,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("first query");
|
||||
assert_eq!(
|
||||
@@ -1060,6 +1292,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query existing index");
|
||||
assert_eq!(
|
||||
@@ -1078,6 +1311,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query refreshed index");
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user