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
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${MNOTE_LIGHTRAG_ENV_FILE:-/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env}"
NPX="${MNOTE_NPX:-npx}"
read_env_value() {
local key="$1"
local file="$2"
if [[ ! -f "$file" ]]; then
return 0
fi
grep -E "^${key}=" "$file" | tail -n 1 | sed -E "s/^${key}=//" | sed -E 's/^"(.*)"$/\1/' | sed -E "s/^'(.*)'$/\1/"
}
HOST_VALUE="${LIGHTRAG_HOST:-$(read_env_value HOST "$ENV_FILE")}"
PORT_VALUE="${LIGHTRAG_PORT:-$(read_env_value PORT "$ENV_FILE")}"
API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE")}"
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
PORT_VALUE="${PORT_VALUE:-9621}"
export LIGHTRAG_SERVER_URL="${LIGHTRAG_SERVER_URL:-http://${HOST_VALUE}:${PORT_VALUE}}"
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
export LIGHTRAG_TIMEOUT_MS="${LIGHTRAG_TIMEOUT_MS:-180000}"
exec "$NPX" -y l-pw2c-lightrag-server-mcp@1.2.2 "$@"
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""MNote LightRAG MCP facade.
This is intentionally thin: LightRAG owns retrieval, MNote owns citation/open
mapping through its source registry.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
DEFAULT_ENV_FILE = "/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"
DEFAULT_MNOTE_WEB_URL = "http://127.0.0.1:3000"
DEFAULT_ROOT_URI = "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"
DEFAULT_WORKSPACE_ID = "local-ws:mnote-e2e:my-space"
mcp = FastMCP("MNote-LightRAG-Server")
def _read_env_value(key: str, env_file: str) -> str:
path = Path(env_file)
if not path.exists():
return ""
for line in path.read_text(encoding="utf-8").splitlines():
if not line.startswith(f"{key}="):
continue
value = line.split("=", 1)[1].strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
return value
return ""
def _lightrag_base_url() -> str:
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
host = os.environ.get("LIGHTRAG_HOST") or _read_env_value("HOST", env_file) or "127.0.0.1"
port = os.environ.get("LIGHTRAG_PORT") or _read_env_value("PORT", env_file) or "9621"
return f"http://{host}:{port}".rstrip("/")
def _lightrag_api_key() -> str:
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
return os.environ.get("LIGHTRAG_API_KEY") or _read_env_value("LIGHTRAG_API_KEY", env_file)
def _mnote_web_url() -> str:
return os.environ.get("MNOTE_WEB_URL", DEFAULT_MNOTE_WEB_URL).rstrip("/")
def _mnote_headers() -> dict[str, str]:
return {
"content-type": "application/json",
"x-mnote-actor-id": os.environ.get("MNOTE_ACTOR_ID", "mnote-e2e"),
"x-mnote-actor-type": os.environ.get("MNOTE_ACTOR_TYPE", "user"),
}
async def _request_lightrag(path: str, *, method: str = "GET", json_body: Any = None) -> Any:
headers = {"accept": "application/json"}
api_key = _lightrag_api_key()
if api_key:
# 当前 LightRAG /query 接受 X-API-KeyBearer 在本机版本会返回 Invalid token。
headers["X-API-Key"] = api_key
async with httpx.AsyncClient(timeout=180) as client:
response = await client.request(
method,
f"{_lightrag_base_url()}{path}",
headers=headers,
json=json_body,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
@mcp.tool(
name="verify_server_health",
description="Check whether the configured local LightRAG server is healthy.",
)
async def verify_server_health() -> Any:
return await _request_lightrag("/health")
@mcp.tool(
name="check_indexing_status",
description="Check the LightRAG document processing pipeline status.",
)
async def check_indexing_status() -> Any:
return await _request_lightrag("/documents/pipeline_status")
@mcp.tool(
name="list_all_docs",
description="List documents currently known to LightRAG.",
)
async def list_all_docs() -> Any:
return await _request_lightrag("/documents")
@mcp.tool(
name="query_knowledge_graph",
description="Search the local LightRAG knowledge base. Use mix by default; references include provider file_path/chunk_id for MNote citation mapping.",
)
async def query_knowledge_graph(
prompt: str,
search_mode: str = "mix",
limit: int = 60,
context_only: bool = False,
prompt_only: bool = False,
include_references: bool = True,
include_chunk_content: bool = True,
) -> Any:
mode_aliases = {
"keyword": "naive",
"semantic": "hybrid",
}
mode = mode_aliases.get(search_mode, search_mode)
body = {
"query": prompt,
"mode": mode,
"top_k": limit,
"chunk_top_k": limit,
"only_need_context": context_only,
"only_need_prompt": prompt_only,
"include_references": include_references,
"include_chunk_content": include_chunk_content,
"response_type": "Multiple Paragraphs",
}
return await _request_lightrag("/query", method="POST", json_body=body)
@mcp.tool(
name="open_mnote_reference",
description="Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry.",
)
async def open_mnote_reference(
file_path: str,
chunk_id: str = "",
reference_id: str = "",
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
include_registry: bool = False,
) -> Any:
body: dict[str, Any] = {
"workspaceId": workspace_id,
"rootUri": root_uri,
"filePath": file_path,
}
if chunk_id:
body["chunkId"] = chunk_id
if reference_id:
body["referenceId"] = reference_id
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{_mnote_web_url()}/api/knowledge-rag/open-reference",
headers=_mnote_headers(),
json=body,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
if not include_registry and isinstance(payload, dict):
payload = {key: value for key, value in payload.items() if key != "registry"}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
if __name__ == "__main__":
mcp.run()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${MNOTE_LIGHTRAG_ENV_FILE:-/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env}"
PYTHON="${MNOTE_PYTHON:-/usr/bin/python3}"
SERVER="${MNOTE_LIGHTRAG_MCP_SERVER:-/mnt/Data1T/mnote/scripts/mnote-lightrag-mcp-server.py}"
read_env_value() {
local key="$1"
local file="$2"
if [[ ! -f "$file" ]]; then
return 0
fi
grep -E "^${key}=" "$file" | tail -n 1 | sed -E "s/^${key}=//" | sed -E 's/^"(.*)"$/\1/' | sed -E "s/^'(.*)'$/\1/"
}
HOST_VALUE="${LIGHTRAG_HOST:-$(read_env_value HOST "$ENV_FILE")}"
PORT_VALUE="${LIGHTRAG_PORT:-$(read_env_value PORT "$ENV_FILE")}"
API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE")}"
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
PORT_VALUE="${PORT_VALUE:-9621}"
if [[ ! -x "$PYTHON" ]]; then
echo "python not found or not executable: $PYTHON" >&2
exit 127
fi
if [[ ! -f "$SERVER" ]]; then
echo "MNote LightRAG MCP server not found: $SERVER" >&2
exit 127
fi
export LIGHTRAG_HOST="$HOST_VALUE"
export LIGHTRAG_PORT="$PORT_VALUE"
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
exec "$PYTHON" "$SERVER" "$@"
+3 -3
View File
@@ -137,10 +137,10 @@ async function main() {
const currentRunId = route.request().url().split("/").pop() || runId;
captured.push({ kind: "events", method: route.request().method(), body: "" });
const evidenceEvents = Array.from({ length: 24 }, (_, index) => {
const callId = `call_smoke_evidence_${index}`;
const callId = `call_smoke_knowledge_rag_${index}`;
return (
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", args: { query: `evidence ${index}` } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", summary: `证据 ${index}`, auditId: `audit_smoke_evidence_${index}` })}\n\n`
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", args: { query: `evidence ${index}` } })}\n\n` +
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", summary: `资料库 ${index}`, auditId: `audit_smoke_knowledge_rag_${index}` })}\n\n`
);
}).join("");
if (runRequestCount > 1) {
+1 -1
View File
@@ -54,7 +54,7 @@ function runtimeInputToolPlan() {
dryRun: false,
},
tool: {
tool: "docs_search",
tool: "mnote.knowledge_rag.query",
kind: "query",
mode: "plan",
argsJson: { query: "Rust Web islands" },
@@ -13,6 +13,7 @@ const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-she
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
@@ -114,6 +115,60 @@ async function newestAssistantLinks(page, initialCount) {
}, initialCount);
}
async function clickNewestAssistantCitation(page, initialCount, expectedResource) {
const total = await page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.count();
const latestIndex = Math.max(initialCount, total - 1);
const latestMessage = page
.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')
.nth(latestIndex);
const citationLink = latestMessage.locator('a[data-page-ai-citation-link="true"]').first();
await citationLink.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const popupPromise = page.waitForEvent("popup", { timeout: 5_000 }).catch(() => null);
await citationLink.click({ timeout: UI_TIMEOUT_MS });
const openedPage = (await popupPromise) || page;
await openedPage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await openedPage.waitForFunction(
(resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
return panel && !panel.hidden;
},
expectedResource,
{ timeout: UI_TIMEOUT_MS },
);
await openedPage.waitForTimeout(1500);
const state = await openedPage.evaluate((resourcePath) => {
const panel = document.querySelector(
`[data-mnote-resource-tab-panel][data-resource-path="${CSS.escape(resourcePath)}"]`,
);
const activeTab = document.querySelector(".mnote-main-tab.is-active, [data-mnote-tab-kind].is-active");
const image = panel ? panel.querySelector("img, [data-mnote-image-viewer], [data-mnote-resource-image]") : null;
return {
url: location.href,
openedInPopup: window.opener != null,
activeTabText: activeTab ? activeTab.textContent.trim().slice(0, 120) : "",
activeTabKind: activeTab ? activeTab.getAttribute("data-mnote-tab-kind") : "",
panelVisible: !!panel && !panel.hidden,
panelResourcePath: panel ? panel.getAttribute("data-resource-path") : "",
panelLocator: panel ? panel.getAttribute("data-mnote-evidence-locator") : "",
panelBlockId: panel ? panel.getAttribute("data-mnote-evidence-block-id") : "",
imageVisible: !!image,
};
}, expectedResource);
await openedPage.screenshot({ path: CITATION_OPEN_SCREENSHOT_PATH, fullPage: true });
assert.equal(state.panelVisible, true, `点击 AI citation 后未打开资源 panel: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.panelResourcePath, expectedResource, `点击 AI citation 后资源路径不匹配: ${JSON.stringify(state, null, 2)}`);
assert(
state.url.includes("resourceTab=") || state.url.includes("resourcePath="),
`点击 AI citation 后 URL 缺少资源定位参数: ${JSON.stringify(state, null, 2)}`,
);
return state;
}
function summarizeRun(body) {
return {
workspaceId: body.workspaceId || "",
@@ -228,6 +283,7 @@ async function main() {
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const citationOpenState = await clickNewestAssistantCitation(page, assistantCount, "新页面233155/image copy 6.png");
const result = {
ok: true,
baseUrl: BASE_URL,
@@ -238,9 +294,13 @@ async function main() {
assistantLinks,
capturedRuns: capturedRuns.map(summarizeRun),
capturedRunsFullPath: path.join(OUTPUT_DIR, "captured-runs-full.json"),
citationOpenState,
pageErrors,
consoleErrors,
screenshot: SCREENSHOT_PATH,
screenshots: {
answer: SCREENSHOT_PATH,
citationOpen: CITATION_OPEN_SCREENSHOT_PATH,
},
};
fs.writeFileSync(
path.join(OUTPUT_DIR, "captured-runs-full.json"),
@@ -78,6 +78,14 @@ async function main() {
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 LightRAG 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
assert(String(knowledgePayload.results?.[0]?.snippet || "").includes(SHORT_QUERY), `资料库结果未包含 ${SHORT_QUERY}: ${JSON.stringify(knowledgePayload.results?.[0], null, 2)}`);
assert(knowledgePayload.references?.some((reference) => reference.matchSource === "lightrag_search"), `2 字 query 应走 LightRAG search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
const firstKnowledgeResult = knowledgePayload.results?.[0] || {};
const firstKnowledgeCitation = knowledgePayload.citations?.[0] || {};
assert(firstKnowledgeResult.displayQuote && firstKnowledgeResult.locatorEvidenceText, `搜索结果缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(firstKnowledgeResult, null, 2)}`);
assert(firstKnowledgeCitation.citationId && firstKnowledgeCitation.displayQuote && firstKnowledgeCitation.locatorEvidenceText, `citations[] 缺少统一引用字段: ${JSON.stringify(firstKnowledgeCitation, null, 2)}`);
assert(
!/(?:<\/?e(?:q(?:uation)?)?\b|<\/?drawing\b|format=["']?latex|\blatex\b)/i.test(String(firstKnowledgeResult.displayQuote || "")),
`displayQuote 仍暴露原始公式/绘图标记: ${firstKnowledgeResult.displayQuote}`
);
const tooShortSearch = await context.request.post(`${BASE_URL}/api/knowledge-rag/search`, {
data: {
@@ -81,6 +81,11 @@ async function apiSearch(context) {
const payload = await response.json();
const results = Array.isArray(payload.results) ? payload.results : [];
assert(results.length >= TOP_N, `资料库结果不足 ${TOP_N} 条: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
assert(Array.isArray(payload.citations) && payload.citations.length >= TOP_N, `资料库结果缺少 citations[]: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
results.slice(0, TOP_N).forEach((item, index) => {
assert(item.displayQuote && item.locatorEvidenceText, `${index + 1} 条缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(item, null, 2)}`);
assert(!/(?:<\/?e(?:q(?:uation)?)?\b|<\/?drawing\b|format=["']?latex|\blatex\b)/i.test(String(item.displayQuote || "")), `${index + 1} 条 displayQuote 仍暴露原始公式/绘图标记: ${item.displayQuote}`);
});
const blockIds = results.map((item) => item?.locator?.blockId).filter(Boolean);
assert.equal(new Set(blockIds).size, blockIds.length, `搜索结果仍有同段落重复: ${JSON.stringify(blockIds)}`);
return results.slice(0, TOP_N);