Improve local evidence search and AI capabilities
This commit is contained in:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user