Improve local evidence search and AI capabilities

This commit is contained in:
lix-2026
2026-06-05 23:00:53 +08:00
parent a1dcfc9f76
commit 4dbd9a978b
52 changed files with 6415 additions and 527 deletions
+302
View File
@@ -229,6 +229,25 @@ pub async fn documents(
EvidenceSearchMode::Hybrid,
&normalized_query,
);
let limit = body.limit.unwrap_or(30).max(1) as usize;
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
local_search_index::query_evidence_sqlite_results_with_mode(
&root_path,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.exact.unwrap_or(false),
)?
.unwrap_or_default()
} else {
Vec::new()
};
let (result, evidence_results) = merge_local_search_with_evidence_results(
result,
evidence_results,
direct_evidence_results,
limit,
);
(result, evidence_results)
} else {
let result = load_search_results_with_filters(
@@ -419,6 +438,12 @@ pub async fn update_local_index_settings(
&effective_workspace_id,
&root_path,
)?;
let refreshed = local_search_index::refresh_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
)?;
let status = local_search_index::local_index_status_with_settings(
&root_path,
root_uri,
@@ -434,6 +459,7 @@ pub async fn update_local_index_settings(
Json(json!({
"ok": true,
"settings": settings,
"index": refreshed,
"result": status,
"meta": {
"owner": "mnote-web",
@@ -446,6 +472,75 @@ pub async fn update_local_index_settings(
))
}
fn merge_local_search_with_evidence_results(
mut result: Value,
mut evidence_results: Vec<EvidenceSearchResult>,
direct_evidence_results: Vec<EvidenceSearchResult>,
limit: usize,
) -> (Value, Vec<EvidenceSearchResult>) {
if direct_evidence_results.is_empty() {
return (result, evidence_results);
}
let mut result_items = result
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut seen_evidence_ids = evidence_results
.iter()
.map(|item| item.evidence_id.clone())
.collect::<std::collections::HashSet<_>>();
for evidence in direct_evidence_results {
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
continue;
}
result_items.push(search_result_from_evidence(&evidence));
evidence_results.push(evidence);
}
if let Some(map) = result.as_object_mut() {
map.insert("results".into(), Value::Array(result_items));
}
(result, evidence_results)
}
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
let source = &evidence.source;
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
let resource_path = source
.resource_path
.as_deref()
.unwrap_or(source.owner_document_path.as_str());
let title = std::path::Path::new(resource_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(resource_path)
.to_string();
let resource_type = match source.resource_kind {
core_protocol::EvidenceResourceKind::Markdown => "markdown",
core_protocol::EvidenceResourceKind::Pdf => "pdf",
core_protocol::EvidenceResourceKind::Image => "image",
core_protocol::EvidenceResourceKind::Office => "office",
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
core_protocol::EvidenceResourceKind::RawFile => "resource",
};
json!({
"id": format!("evidence:{}", evidence.evidence_id),
"documentId": source.owner_document_id,
"title": title,
"path": source.owner_document_path,
"resourceType": resource_type,
"sourceKind": "local_folder",
"rootUri": source.root_uri,
"snippet": evidence.quote,
"score": evidence.score,
"publicPath": source.open_action.url,
"evidence": evidence_value,
"source": {
"locator": source
},
})
}
fn attach_evidence_to_search_results(
results: Value,
evidence_results: &[EvidenceSearchResult],
@@ -463,6 +558,9 @@ fn attach_evidence_to_search_results(
};
let mut item = item;
if let Some(map) = item.as_object_mut() {
if map.get("evidence").is_some() {
return item;
}
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
map.insert("evidence".into(), evidence_value);
let source = map.entry("source").or_insert_with(|| json!({}));
@@ -793,6 +891,7 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_search_index;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
@@ -998,6 +1097,15 @@ mod tests {
.expect("readme");
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let response = app()
.oneshot(
@@ -1102,6 +1210,104 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-evidence-route-{}",
std::process::id()
));
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-search-evidence","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("README.md"),
"# Search Home\nBodyOnlyEvidenceToken only exists inside evidence.sqlite after refresh.\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-search-evidence",
)
.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-search-evidence",
"documents": [],
"resources": []
}))
.expect("stale search index"),
)
.expect("overwrite search index");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/search/documents")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-search-evidence",
"sourceKind": "local_folder",
"rootUri": root_uri,
"query": "BodyOnlyEvidenceToken",
"limit": 10,
"filters": {
"titleOnly": false,
"includeOcr": true
}
})
.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 results = payload["results"].as_array().expect("results");
let hit = results
.iter()
.find(|item| {
item["snippet"]
.as_str()
.unwrap_or("")
.contains("BodyOnlyEvidenceToken")
})
.expect("evidence sqlite body hit should be promoted to search result");
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
assert_eq!(
hit["evidence"]["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_local_index_refresh_rebuilds_authorized_root() {
let root = std::env::temp_dir().join(format!(
@@ -1156,6 +1362,102 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_index_settings_empty_scope_deletes_index_files() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-settings-delete-route-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-settings-delete","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files","search"]}"#,
)
.expect("manifest");
fs::write(
root.join("docs").join("keep.md"),
"# Keep\nRouteDeleteToken\n",
)
.expect("doc");
let root_uri = format!("file://{}", root.display());
let app = app();
let create_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/search/local-index/settings")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings-delete",
"rootUri": root_uri,
"includePaths": ["docs"],
"scheduleMode": "manual",
"scheduleTime": "02:00",
"runOnChange": false
})
.to_string(),
))
.expect("create settings request"),
)
.await
.expect("create settings response");
assert_eq!(create_response.status(), StatusCode::OK);
assert!(root.join(".mnote/index/search-index.json").exists());
assert!(root.join(".mnote/index/evidence.sqlite").exists());
let delete_response = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/search/local-index/settings")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"workspaceId": "local-ws-settings-delete",
"rootUri": root_uri,
"includePaths": [],
"scheduleMode": "manual",
"scheduleTime": "02:00",
"runOnChange": false
})
.to_string(),
))
.expect("delete settings request"),
)
.await
.expect("delete settings response");
assert_eq!(delete_response.status(), StatusCode::OK);
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
.await
.expect("delete body");
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
assert_eq!(
delete_payload["index"]["indexedPaths"]
.as_array()
.map(Vec::len),
Some(0)
);
assert_eq!(
delete_payload["result"]["settings"]["includePaths"]
.as_array()
.map(Vec::len),
Some(0)
);
assert!(!root.join(".mnote/index/search-index.json").exists());
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
let root = std::env::temp_dir().join(format!(