feat(rag): align LightRAG native citations and MCP bridge

This commit is contained in:
lix-2026
2026-06-09 09:20:56 +08:00
parent 0e8b03daf8
commit 8e3be8b0b7
39 changed files with 2255 additions and 2732 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -212,7 +212,7 @@ mod tests {
"dryRun": false
},
"tool": {
"tool": "docs_search",
"tool": "mnote.knowledge_rag.query",
"kind": "query",
"mode": "plan",
"argsJson": {"query": "Rust Web"},
+18 -126
View File
@@ -3,8 +3,8 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
resource, skill, ToolCallInput,
artifact, block, context_tools, doc, knowledge_rag, manifest, onlyoffice_live, page, resource,
skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -360,13 +360,15 @@ pub(crate) async fn execute_mnote_tool_call(
}
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
"mnote.evidence.search" | "mnote.evidence.read" | "mnote.evidence.open" => {
"docs_search"
| "docs_read"
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open" => {
Err(WebError::new(
StatusCode::GONE,
"mnote_evidence_tools_retired",
"旧 evidence / LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
"docs/evidence/LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
)
.with_context(&context))
}
@@ -5466,38 +5468,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
let root =
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", 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-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(
root.join("README.md"),
"# Evidence Home\n\ncompat-evidence-token 正文\n",
)
.expect("markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
crate::routes::local_search_index::refresh_local_search_index(
&root,
&root_uri,
"local-ws-docs-search",
)
.expect("refresh");
async fn hermes_tools_legacy_docs_search_is_retired() {
let response = app()
.oneshot(
Request::builder()
@@ -5511,7 +5482,7 @@ mod tests {
"toolName": "docs_search",
"workspaceId": "local-ws-docs-search",
"sourceKind": "local_folder",
"rootUri": root_uri,
"rootUri": "file:///tmp/mnote-retired-docs-search",
"sessionId": "sess_docs_search",
"runId": "run_docs_search",
"toolCallId": "call_docs_search",
@@ -5529,72 +5500,15 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["compatTool"], "docs_search");
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
let result = payload["result"]["results"]
.as_array()
.and_then(|items| items.first())
.expect("evidence result");
assert_eq!(
result["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["code"].as_str(),
Some("mnote_evidence_tools_retired")
);
assert_eq!(
result["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert!(result["citationMarkdown"]
.as_str()
.is_some_and(|value| value.contains("](/documents/")));
assert!(result["citationUrl"]
.as_str()
.is_some_and(|value| value.contains("resourceTab=")));
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
assert_eq!(
payload["result"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert_eq!(
payload["result"]["runReceipt"]["schema"].as_str(),
Some("mnote.agent_run_receipt.evidence.v1")
);
assert_eq!(
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
assert_eq!(
payload["runReceipt"]["toolCallId"].as_str(),
Some("call_docs_search")
);
assert_eq!(
payload["audit"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
let completed_audit =
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
.into_iter()
.find(|event| event["phase"] == "completed")
.expect("completed audit");
assert_eq!(
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
Some(evidence_id)
);
assert!(payload["result"]["evidence"]
.as_array()
.expect("evidence")
.iter()
.any(|item| item["quote"]
.as_str()
.unwrap_or_default()
.contains("compat-evidence-token")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
@@ -5697,19 +5611,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
let root =
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", 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-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
)
.expect("manifest");
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
async fn hermes_tools_legacy_docs_read_is_retired() {
let response = app()
.oneshot(
Request::builder()
@@ -5724,7 +5626,7 @@ mod tests {
"workspaceId": "local-ws-docs-read",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"rootUri": "file:///tmp/mnote-retired-docs-read",
"sessionId": "sess_docs_read",
"runId": "run_docs_read",
"toolCallId": "call_docs_read",
@@ -5741,25 +5643,15 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["compatTool"], "docs_read");
assert_eq!(
payload["result"]["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["code"].as_str(),
Some("mnote_evidence_tools_retired")
);
assert_eq!(
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert!(payload["result"]["document"]
.to_string()
.contains("legacy docs read"));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,12 @@ use crate::routes::local_markdown_parser::{
};
use crate::routes::local_ocr;
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
#[cfg(test)]
use core_protocol::EvidenceSearchMatchInfo;
#[cfg(test)]
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
use core_protocol::{
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
@@ -84,6 +87,7 @@ struct LocalSearchResource {
updated_at: u128,
}
#[cfg(test)]
pub(crate) fn query_local_search_index(
root_path: &Path,
root_uri: &str,
@@ -636,6 +640,7 @@ pub(crate) fn query_evidence_sqlite_results(
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
}
#[cfg(test)]
pub(crate) fn query_evidence_sqlite_results_with_mode(
root_path: &Path,
query: &str,
@@ -703,6 +708,7 @@ pub(crate) fn query_evidence_sqlite_results_with_mode(
Ok(Some(results))
}
#[cfg(test)]
pub(crate) fn read_evidence_sqlite_context(
root_path: &Path,
locator: &EvidenceLocator,
@@ -777,6 +783,7 @@ pub(crate) fn read_evidence_sqlite_context(
Ok(Some(results))
}
#[cfg(test)]
pub(crate) fn query_evidence_graph_results(
root_path: &Path,
query: &str,
@@ -839,6 +846,7 @@ pub(crate) fn query_evidence_graph_results(
Ok(Some(results))
}
#[cfg(test)]
fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchResult> {
let edge_id: String = row.get(0)?;
let edge_type: String = row.get(1)?;
@@ -865,6 +873,7 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
})
}
#[cfg(test)]
fn evidence_locator_matches(
block_id: &str,
source: &EvidenceLocator,
@@ -877,6 +886,7 @@ fn evidence_locator_matches(
&& source.source_map_path == locator.source_map_path
}
#[cfg(test)]
fn query_evidence_sqlite_fts(
connection: &Connection,
fts_query: &str,
@@ -929,6 +939,7 @@ fn query_evidence_sqlite_fts(
rows.map_err(sqlite_error)
}
#[cfg(test)]
fn query_evidence_sqlite_like(
connection: &Connection,
query: &str,
@@ -980,6 +991,7 @@ fn query_evidence_sqlite_like(
rows.map_err(sqlite_error)
}
#[cfg(test)]
fn query_evidence_sqlite_fuzzy(
connection: &Connection,
query: &str,
@@ -1075,6 +1087,7 @@ fn query_evidence_sqlite_fuzzy(
.collect())
}
#[cfg(test)]
fn evidence_sqlite_row_parts(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<(String, String, EvidenceLocator)> {
@@ -1087,6 +1100,7 @@ fn evidence_sqlite_row_parts(
Ok((block_id, text, source))
}
#[cfg(test)]
fn evidence_result_from_sqlite_row(
row: &rusqlite::Row<'_>,
query: &str,
@@ -1118,6 +1132,7 @@ fn evidence_result_from_sqlite_row(
})
}
#[cfg(test)]
fn evidence_fts_phrase(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
@@ -3631,6 +3646,7 @@ struct EvidenceQueryTerm {
}
#[derive(Debug, Clone)]
#[cfg_attr(not(test), allow(dead_code))]
struct EvidenceTextMatch {
score: f64,
matched_terms: Vec<String>,
@@ -3640,6 +3656,7 @@ struct EvidenceTextMatch {
}
impl EvidenceTextMatch {
#[cfg(test)]
fn into_match_info(self, rank: Option<u32>) -> EvidenceSearchMatchInfo {
EvidenceSearchMatchInfo {
rank,
@@ -3821,6 +3838,7 @@ fn push_unique(values: &mut Vec<String>, value: String) {
}
}
#[cfg(test)]
fn ocr_search_snippet(body: &str, query: &str) -> String {
let normalized_body = body.replace('\n', " ");
let normalized_query = query.trim();
@@ -3850,6 +3868,7 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
}
}
#[cfg(test)]
fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> String {
let normalized_body = body.replace('\n', " ");
let normalized_query = query.trim();
@@ -3873,6 +3892,7 @@ fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> St
ocr_search_snippet(body, query)
}
#[cfg(test)]
fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String {
let start = body[..byte_index]
.char_indices()
@@ -3883,6 +3903,7 @@ fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String
body[start..].chars().take(len).collect()
}
#[cfg(test)]
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
if query.is_empty() {
return None;
+79 -2
View File
@@ -994,6 +994,14 @@ pub struct OfficePreviewQuery {
source_map_path: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
#[serde(default, alias = "paragraphOrdinal")]
paragraph_ordinal: Option<String>,
#[serde(default, alias = "paraIdStart")]
para_id_start: Option<String>,
#[serde(default, alias = "paraIdEnd")]
para_id_end: Option<String>,
#[serde(default, alias = "textFingerprint")]
text_fingerprint: Option<String>,
#[serde(default, alias = "evidenceText")]
evidence_text: Option<String>,
#[serde(default, alias = "searchQuery")]
@@ -1032,6 +1040,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let target_bbox = query.bbox.unwrap_or_default();
let target_source_map_path = query.source_map_path.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let target_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default();
let target_para_id_start = query.para_id_start.unwrap_or_default();
let target_para_id_end = query.para_id_end.unwrap_or_default();
let target_text_fingerprint = query.text_fingerprint.unwrap_or_default();
let target_evidence_text = query.evidence_text.unwrap_or_default();
let target_search_query = query.search_query.unwrap_or_default();
let html = format!(
@@ -1080,7 +1092,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-paragraph-ordinal="{target_paragraph_ordinal}" data-evidence-para-id-start="{target_para_id_start}" data-evidence-para-id-end="{target_para_id_end}" data-evidence-text-fingerprint="{target_text_fingerprint}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
<main class="mnote-office-preview">
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
</main>
@@ -1099,6 +1111,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
let evidenceBbox = body.dataset.evidenceBbox || '';
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
let evidenceBlockId = body.dataset.evidenceBlockId || '';
let evidenceParagraphOrdinal = body.dataset.evidenceParagraphOrdinal || '';
let evidenceParaIdStart = body.dataset.evidenceParaIdStart || '';
let evidenceParaIdEnd = body.dataset.evidenceParaIdEnd || '';
let evidenceTextFingerprint = body.dataset.evidenceTextFingerprint || '';
let evidenceText = body.dataset.evidenceText || '';
let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
let currentPptxBuffer = null;
@@ -1484,6 +1500,17 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
}}
const prefixWindow = cleaned.slice(0, 260);
if (queryCompact.length >= 2) {{
const prefixCompact = compactEvidenceText(prefixWindow);
const compactIndex = prefixCompact.indexOf(queryCompact);
if (compactIndex >= 0) {{
const queryIndex = prefixWindow.indexOf(evidenceSearchQuery);
const start = queryIndex >= 0 ? queryIndex : 0;
const queryWindow = prefixWindow.slice(start, start + 96).split(/[;]/)[0];
push(queryWindow, {{ allowShort: true }});
queryWindow.split(/[,]/).slice(0, 2).forEach(part => push(part, {{ allowShort: true }}));
}}
}}
const catalogMatches = prefixWindow.match(/[^,;#]{{2,56}}[,]\s*[0-9-]{{1,5}}/g) || [];
catalogMatches.slice(0, 8).forEach(match => {{
const value = normalizeEvidenceText(match);
@@ -1711,6 +1738,42 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
return markEvidenceTarget(marker);
}}
function evidenceParagraphElements() {{
if (!viewer) return [];
const paragraphs = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
.filter(node => node instanceof HTMLElement)
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
if (paragraphs.length) return paragraphs;
return Array.from(viewer.querySelectorAll('div, section.docx, section.mnote-docx'))
.filter(node => node instanceof HTMLElement)
.filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
}}
function scrollToEvidenceParagraphOrdinal() {{
const ordinal = Number(evidenceParagraphOrdinal);
if (!Number.isFinite(ordinal) || ordinal < 0) return false;
const elements = evidenceParagraphElements();
if (!elements.length) return false;
const anchors = evidenceParagraphAnchors(evidenceText || evidenceSearchQuery);
const indices = [];
for (let offset = 0; offset <= 4; offset += 1) {{
if (offset === 0) indices.push(ordinal);
else {{
indices.push(ordinal - offset);
indices.push(ordinal + offset);
}}
}}
let best = null;
for (const index of indices) {{
if (index < 0 || index >= elements.length) continue;
const element = elements[index];
const score = anchors.length ? scoreEvidenceParagraphElement(element, anchors) : 0;
if (!best || score > best.score) best = {{ element, score }};
if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
}}
return best ? markEvidenceTarget(best.element) : false;
}}
function scrollToEvidencePageFallback() {{
if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
@@ -1719,16 +1782,18 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
async function applyEvidenceLocator() {{
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText)) return;
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText && !evidenceParagraphOrdinal && !evidenceTextFingerprint)) return;
try {{
const sourceMap = await fetchEvidenceSourceMap();
const block = findEvidenceBlockInSourceMap(sourceMap);
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (block && scrollToEvidenceParagraph(block.text)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
if (block && scrollToEvidenceTextCandidates(block.text)) return;
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
}} catch (_) {{}}
if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
scrollToEvidencePageFallback();
@@ -1740,12 +1805,20 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
evidenceBbox = String(next.bbox || '');
evidenceSourceMapPath = String(next.sourceMapPath || '');
evidenceBlockId = String(next.blockId || '');
evidenceParagraphOrdinal = String(next.paragraphOrdinal || '');
evidenceParaIdStart = String(next.paraIdStart || '');
evidenceParaIdEnd = String(next.paraIdEnd || '');
evidenceTextFingerprint = String(next.textFingerprint || '');
evidenceText = String(next.evidenceText || next.query || '');
evidenceSearchQuery = String(next.searchQuery || '');
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
body.dataset.evidenceBbox = evidenceBbox;
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
body.dataset.evidenceBlockId = evidenceBlockId;
body.dataset.evidenceParagraphOrdinal = evidenceParagraphOrdinal;
body.dataset.evidenceParaIdStart = evidenceParaIdStart;
body.dataset.evidenceParaIdEnd = evidenceParaIdEnd;
body.dataset.evidenceTextFingerprint = evidenceTextFingerprint;
body.dataset.evidenceText = evidenceText;
body.dataset.evidenceSearchQuery = evidenceSearchQuery;
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
@@ -1969,6 +2042,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
target_bbox = escape_html(&target_bbox),
target_source_map_path = escape_html(&target_source_map_path),
target_block_id = escape_html(&target_block_id),
target_paragraph_ordinal = escape_html(&target_paragraph_ordinal),
target_para_id_start = escape_html(&target_para_id_start),
target_para_id_end = escape_html(&target_para_id_end),
target_text_fingerprint = escape_html(&target_text_fingerprint),
target_evidence_text = escape_html(&target_evidence_text),
);
let mut response = Html(html).into_response();