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
@@ -1,245 +0,0 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::routes;
use axum::http::StatusCode;
use core_protocol::{
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
};
use serde_json::{json, Value};
pub async fn evidence_search(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let body = evidence_search_request(input, context)?;
routes::evidence::search_payload(state, context, body).await
}
pub async fn evidence_read(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_evidence_read_payload_invalid",
format!("Evidence read 参数无效: {error}"),
)
.with_context(context)
})?;
routes::evidence::read_payload(state, context, body).await
}
pub async fn evidence_open(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_evidence_open_payload_invalid",
format!("Evidence open 参数无效: {error}"),
)
.with_context(context)
})?;
routes::evidence::open_payload(state, context, body).await
}
pub async fn legacy_docs_search(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let payload = evidence_search(state, context, input).await?;
Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_search",
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
"source": "mnote.evidence.search",
}))
}
pub async fn legacy_docs_read(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if input.arg_value("locator").is_some() {
let payload = evidence_read(state, context, input).await?;
return Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_read",
"result": payload,
"source": "mnote.evidence.read",
}));
}
let document = doc::doc_fetch(state, context, input).await?;
let locator = legacy_document_locator(input);
Ok(json!({
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
"compatTool": "docs_read",
"documentId": input.effective_document_id(),
"document": document,
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
"source": {
"tool": "mnote.doc.fetch",
"locator": locator,
},
}))
}
fn evidence_search_request(
input: &ToolCallInput,
context: &RequestContext,
) -> Result<EvidenceSearchRequest, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("scope").is_none() {
let query = input.arg_string("query").ok_or_else(|| {
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
WebError::bad_request_code(
"mnote_evidence_workspace_required",
"Evidence 搜索缺少 workspaceId",
)
.with_context(context)
})?;
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
.with_context(context)
})?;
let include_resources = input
.args
.as_ref()
.and_then(|value| value.get("includeResources"))
.and_then(Value::as_bool)
.unwrap_or(true);
let include_ocr = input
.args
.as_ref()
.and_then(|value| value.get("includeOcr"))
.and_then(Value::as_bool)
.unwrap_or(true);
let target_document_id = input
.effective_document_id()
.or_else(|| input.arg_string("pageId"))
.or_else(|| input.arg_string("targetDocumentId"));
args = json!({
"query": query,
"scope": {
"workspaceId": workspace_id,
"rootUri": root_uri,
"targetDocumentId": target_document_id,
"includeResources": include_resources,
"includeOcr": include_ocr,
},
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
"topK": input
.args
.as_ref()
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
.and_then(Value::as_u64)
.unwrap_or(8),
});
}
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
WebError::new(
StatusCode::BAD_REQUEST,
"mnote_evidence_search_payload_invalid",
format!("Evidence search 参数无效: {error}"),
)
.with_context(context)
})
}
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
let root_uri = local_root_uri_for_evidence(input)?;
let document_id = input.effective_document_id()?;
let owner_document_path =
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
Some(EvidenceLocator {
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
root_uri: root_uri.clone(),
owner_document_id: document_id,
owner_document_path: owner_document_path.clone(),
resource_path: Some(owner_document_path.clone()),
resource_kind: EvidenceResourceKind::Markdown,
page: None,
bbox: None,
section_path: Vec::new(),
line_range: None,
char_range: None,
block_id: None,
source_map_path: None,
open_action: EvidenceOpenAction {
action_type: "mnote.open_resource_locator".into(),
url: "/".into(),
params: json!({
"rootUri": root_uri,
"ownerDocumentPath": owner_document_path,
}),
},
})
}
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
input.effective_root_uri().or_else(|| {
input
.arg_value("aiAccessScope")
.and_then(|scope| {
scope
.get("allowedRoots")
.or_else(|| scope.get("allowed_roots"))
.cloned()
})
.and_then(|allowed_roots| {
allowed_roots.as_array().and_then(|roots| {
roots
.iter()
.filter_map(|root| {
root.get("rootUri")
.or_else(|| root.get("root_uri"))
.and_then(Value::as_str)
})
.map(str::trim)
.find(|root_uri| !root_uri.is_empty())
.map(ToOwned::to_owned)
})
})
})
}
fn document_path_from_local_id(document_id: &str) -> Option<String> {
let encoded = document_id.trim().strip_prefix("local-md:")?;
decode_local_id_segment(encoded)
}
fn decode_local_id_segment(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'~' {
if index + 2 >= bytes.len() {
return None;
}
let hex = &value[index + 1..index + 3];
let byte = u8::from_str_radix(hex, 16).ok()?;
decoded.push(byte);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
@@ -112,21 +112,33 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
.collect::<Vec<_>>()
})
.unwrap_or_default();
let citation_references = citation_references_for_ui(&references);
let citations = citation_references
let payload_citations = payload
.get("citations")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let citations = if payload_citations.is_empty() {
citation_references_for_ui(&references)
.into_iter()
.map(compact_citation_for_agent)
.collect::<Vec<_>>()
} else {
citation_values_for_ui(&payload_citations)
.into_iter()
.map(compact_citation_for_agent)
.collect::<Vec<_>>()
};
let citation_markdowns = citations
.iter()
.filter_map(|reference| {
reference
.filter_map(|citation| {
citation
.get("citationMarkdown")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
let ui_citations = citations
.iter()
.map(|citation| json!({ "citationMarkdown": citation }))
.collect::<Vec<_>>();
let ui_citations = citations.clone();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
@@ -134,6 +146,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"citations": citations,
"citationMarkdowns": citation_markdowns,
"uiCitations": ui_citations,
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
@@ -148,7 +161,13 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
fn compact_reference_for_agent(reference: &Value) -> Value {
let quote = reference
.get("quote")
.get("displayQuote")
.and_then(Value::as_str)
.or_else(|| reference.get("quote").and_then(Value::as_str))
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
let locator_evidence_text = reference
.get("locatorEvidenceText")
.and_then(Value::as_str)
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
@@ -159,28 +178,74 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
"quote": quote,
"displayQuote": reference.get("displayQuote").cloned().unwrap_or(Value::Null),
"locatorEvidenceText": locator_evidence_text,
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"contentDiagnostics": quote_diagnostics,
"citationDiagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
fn compact_citation_for_agent(citation: &Value) -> Value {
let display_quote = citation
.get("displayQuote")
.and_then(Value::as_str)
.or_else(|| citation.get("quote").and_then(Value::as_str))
.map(|value| value.chars().take(420).collect::<String>())
.unwrap_or_default();
let locator_evidence_text = citation
.get("locatorEvidenceText")
.and_then(Value::as_str)
.map(|value| value.chars().take(420).collect::<String>())
.unwrap_or_default();
json!({
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
"sourcePath": citation.get("sourcePath").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"filePath": citation.get("filePath").or_else(|| citation.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
"chunkId": citation.get("chunkId").or_else(|| citation.get("lightRagChunkId")).cloned().unwrap_or(Value::Null),
"blockId": citation.get("blockId").cloned().unwrap_or(Value::Null),
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"quote": display_quote,
"displayQuote": citation.get("displayQuote").cloned().unwrap_or(Value::Null),
"locatorEvidenceText": locator_evidence_text,
"searchQuery": citation.get("searchQuery").cloned().unwrap_or(Value::Null),
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"citationMarkdown": citation.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": citation.get("citationUrl").cloned().unwrap_or(Value::Null),
"diagnostics": citation.get("diagnostics").or_else(|| citation.get("citationDiagnostics")).cloned().unwrap_or(Value::Null),
})
}
fn citation_references_for_ui(references: &[Value]) -> Vec<&Value> {
let has_precise = references.iter().any(|reference| {
citation_values_for_ui(references)
}
fn citation_values_for_ui(values: &[Value]) -> Vec<&Value> {
let has_precise = values.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
references
values
.iter()
.filter(|reference| {
reference
@@ -269,6 +334,41 @@ mod tests {
);
}
#[test]
fn compact_query_result_prefers_structured_citations() {
let payload = json!({
"references": [{
"sourceRootRelativePath": "docs/a.docx",
"displayQuote": "raw reference should not be source card",
"locatorDegraded": false,
"citationMarkdown": "[docs/a.docx](/documents/a)"
}],
"citations": [{
"schema": "mnote.knowledge_rag.citation.v1",
"citationId": "c0de",
"citationLabel": "[c0de]",
"sourceRootRelativePath": "docs/a.docx",
"headingPath": ["保护基", "硅基保护"],
"displayQuote": "吡咯烷,5 h,90%",
"locatorEvidenceText": "如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。",
"locatorPrecision": "paragraph",
"locatorDegraded": false,
"citationMarkdown": "[docs/a.docx](/documents/a)"
}]
});
let compact = compact_query_result_for_agent(payload);
assert_eq!(compact["citations"][0]["citationId"], "c0de");
assert_eq!(compact["citations"][0]["headingPath"][0], "保护基");
assert_eq!(compact["citations"][0]["displayQuote"], "吡咯烷,5 h,90%");
assert!(compact["citations"][0].get("rawQuote").is_none());
assert_eq!(
compact["uiCitations"][0]["citationMarkdown"].as_str(),
Some("[docs/a.docx](/documents/a)")
);
}
#[test]
fn compact_reference_marks_image_placeholder_as_not_ocr_text() {
let payload = json!({
@@ -290,108 +290,6 @@ fn doc_find_tool() -> Value {
})
}
// 旧 evidence 工具仅保留为历史对照;当前 manifest() 不注册这些工具,资料库问答走 mnote.knowledge_rag.*。
#[allow(dead_code)]
fn evidence_search_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("query".into(), json!({ "type": "string" }));
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert(
"includeResources".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"includeOcr".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"mode".into(),
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
);
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
map.insert(
"scope".into(),
json!({
"type": "object",
"properties": {
"workspaceId": { "type": "string" },
"rootUri": { "type": "string" },
"targetDocumentId": { "type": "string" },
"includeResources": { "type": "boolean" },
"includeOcr": { "type": "boolean" }
}
}),
);
}
json!({
"name": "mnote.evidence.search",
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator、openAction 与可直接放进最终回答的 citationMarkdown 链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
#[allow(dead_code)]
fn evidence_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("locator".into(), json!({ "type": "object" }));
map.insert(
"context".into(),
json!({
"type": "object",
"properties": {
"beforeBlocks": { "type": "integer", "default": 3 },
"afterBlocks": { "type": "integer", "default": 3 },
"includeSectionSummary": { "type": "boolean", "default": true }
}
}),
);
}
json!({
"name": "mnote.evidence.read",
"description": "按 EvidenceLocator 读取原文证据及周边上下文,返回 quote/contextBlocks 与可点击引用链接供回答引用。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["locator"],
"properties": properties
}
})
}
#[allow(dead_code)]
fn evidence_open_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("locator".into(), json!({ "type": "object" }));
}
json!({
"name": "mnote.evidence.open",
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["evidence.read", "page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["locator"],
"properties": properties
}
})
}
fn knowledge_rag_status_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -2,7 +2,6 @@ pub mod artifact;
pub mod block;
pub mod context_tools;
pub mod doc;
pub mod evidence;
pub mod index;
pub mod knowledge_rag;
pub mod manifest;