feat(rag): align LightRAG native citations and MCP bridge
This commit is contained in:
@@ -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 "$@"
|
||||
@@ -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-Key;Bearer 在本机版本会返回 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()
|
||||
@@ -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" "$@"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user