Improve LightRAG knowledge search locator alignment

This commit is contained in:
lix-2026
2026-06-08 20:35:49 +08:00
parent 9551d4c1dc
commit 0e8b03daf8
28 changed files with 5769 additions and 140 deletions
+145 -7
View File
@@ -37,6 +37,7 @@ function debugLog(message) {
}
const toolContextStorage = new AsyncLocalStorage();
const MNOTE_UI_CITATION_QUEUE = [];
const MNOTE_TOOL_NAMES = [
'mnote.skill.read',
@@ -408,6 +409,8 @@ let nextId = 1;
const pendingReqs = new Map(); // id → { resolve, reject }
const requestHandlers = new Map(); // method → handler
const notificationHandlers = new Map(); // method → handler
let rpcServerReady = false;
const queuedRpcLines = [];
function sendMessage(msg) {
const line = JSON.stringify(msg) + '\n';
@@ -434,9 +437,7 @@ function onNotification(method, handler) {
notificationHandlers.set(method, handler);
}
// Start reading NDJSON from stdin
const rl = createInterface({ input: stdin, terminal: false });
rl.on('line', async (raw) => {
async function handleRpcLine(raw) {
const trimmed = raw.trim();
if (!trimmed) return;
@@ -480,6 +481,16 @@ rl.on('line', async (raw) => {
const handler = notificationHandlers.get(msg.method);
if (handler) handler(msg.params);
}
}
// 先接住 stdio 输入,但等全部 handler 注册完成后再处理,避免 initialize 抢跑。
const rl = createInterface({ input: stdin, terminal: false });
rl.on('line', (raw) => {
if (!rpcServerReady) {
queuedRpcLines.push(raw);
return;
}
void handleRpcLine(raw);
});
// ── Helper: send ACP session/update (camelCase per protocol spec) ──
@@ -566,7 +577,129 @@ async function callMnoteTool(toolName, args) {
const text = await response.text().catch(() => '');
throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`);
}
return response.json();
const result = compactMnoteToolResultForReasonix(toolName, await response.json());
const citations = collectUiCitationMarkdowns(result).slice(0, 8);
if (citations.length) MNOTE_UI_CITATION_QUEUE.push(citations);
return result;
}
function compactMnoteToolResultForReasonix(toolName, payload) {
if (toolName !== 'mnote.knowledge_rag.query') return payload;
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload;
const citationMarkdowns = collectUiCitationMarkdowns(result).slice(0, 8);
const citations = citationMarkdowns
.map((citationMarkdown) => ({ citationMarkdown }))
.slice(0, 8);
if (!citations.length || !result || typeof result !== 'object') return payload;
return {
ok: payload?.ok !== false,
schema: result.schema || 'mnote.knowledge_rag.agent_query_result.v1',
uiCitations: citations,
citationRendering: 'MNote UI renders uiCitations after the answer as clickable source locators. Do not copy citationMarkdown into the final answer and do not hand-write /documents links.',
answerCitationPolicy: 'Answer the substance in plain text. Mention source titles only if useful; leave clickable citation insertion to MNote UI.',
answerGuidance: result.answerGuidance || '',
references: Array.isArray(result.references) ? result.references.slice(0, 8) : [],
citations: citationMarkdowns,
sourceScope: result.sourceScope || [],
sourceScopeMode: result.sourceScopeMode || '',
rawScopeFiltered: Boolean(result.rawScopeFiltered),
};
}
function collectUiCitationMarkdowns(value) {
const references = Array.isArray(value?.references) ? value.references : [];
const referenceCitations = [];
if (references.length) {
const hasPrecise = references.some((reference) =>
typeof reference?.citationMarkdown === 'string' &&
reference.citationMarkdown.trim() &&
reference.locatorDegraded !== true
);
const seen = new Set();
for (const reference of references) {
const citation = String(reference?.citationMarkdown || '').trim();
if (!citation || seen.has(citation)) continue;
if (hasPrecise && reference?.locatorDegraded === true) continue;
seen.add(citation);
referenceCitations.push(citation);
}
if (referenceCitations.length) return referenceCitations;
}
const citations = collectCitationMarkdowns(value);
const hasPrecise = citations.some((item) => !isDegradedCitationMarkdown(item));
return citations.filter((citation) => {
return !hasPrecise || !isDegradedCitationMarkdown(citation);
});
}
function isDegradedCitationMarkdown(value) {
const text = String(value || '').toLowerCase();
return text.includes('来源定位降级') || text.includes('locator degraded');
}
function collectCitationMarkdowns(value) {
const out = [];
const seen = new Set();
function add(text) {
const value = String(text || '').trim();
if (!value || seen.has(value)) return;
seen.add(value);
out.push(value);
}
function visit(node) {
if (!node) return;
if (typeof node === 'string') {
const trimmed = node.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
visit(JSON.parse(trimmed));
} catch {}
}
return;
}
if (Array.isArray(node)) {
node.forEach(visit);
return;
}
if (typeof node !== 'object') return;
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
Object.values(node).forEach(visit);
}
visit(value);
return out;
}
function toolResultTextWithUiCitations(rawText) {
const text = String(rawText || '');
let citationMarkdowns = collectUiCitationMarkdownsFromText(text).slice(0, 8);
if (!citationMarkdowns.length && MNOTE_UI_CITATION_QUEUE.length) {
citationMarkdowns = MNOTE_UI_CITATION_QUEUE.shift();
}
const citations = citationMarkdowns.map((citationMarkdown) => ({ citationMarkdown })).slice(0, 8);
if (!citations.length) return text.slice(0, 8000);
const prefix = JSON.stringify({
schema: 'mnote.acp.tool_result_ui_citations.v1',
uiCitations: citations,
citationRendering: 'MNote UI renders these citations after the answer; the model must not hand-write local citation links.',
});
const budget = Math.max(0, 8000 - prefix.length - 2);
return `${prefix}\n${text.slice(0, budget)}`;
}
function collectUiCitationMarkdownsFromText(text) {
const trimmed = String(text || '').trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return collectUiCitationMarkdowns(JSON.parse(trimmed));
} catch {}
const firstLine = trimmed.split('\n')[0]?.trim();
if (firstLine && firstLine !== trimmed && (firstLine.startsWith('{') || firstLine.startsWith('['))) {
try {
return collectUiCitationMarkdowns(JSON.parse(firstLine));
} catch {}
}
}
return collectCitationMarkdowns(trimmed);
}
// ── Register Tools ───────────────────────────────────
@@ -637,7 +770,7 @@ function fallbackMnoteToolSpecs() {
{
mnoteToolName: 'mnote.knowledge_rag.query',
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源不要引用 raw chunks。',
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用。不要在最终回答中手写 citationMarkdown、/documents、mnote:// 或搜索引擎包装链接;MNote 前端会把 uiCitations/citationMarkdown 自动追加成可点击来源不要引用 raw chunks。',
parameters: {
type: 'object',
properties: {
@@ -775,7 +908,7 @@ onRequest('session/new', async (params) => {
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
@@ -841,6 +974,7 @@ onRequest('session/prompt', async (params) => {
const announcedToolKeys = new Set();
const preparingToolCallIds = [];
const inflightToolCallIds = [];
MNOTE_UI_CITATION_QUEUE.length = 0;
function nextToolCallId() {
return `tc_${nextToolCallSeq++}`;
@@ -933,7 +1067,7 @@ onRequest('session/prompt', async (params) => {
}
case 'tool': {
hasToolCall = true;
const resultText = String(ev.content || '').slice(0, 8000);
const resultText = toolResultTextWithUiCitations(ev.content);
emitToolResult(
session.id,
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
@@ -990,4 +1124,8 @@ onNotification('session/cancel', (params) => {
// ── Start ────────────────────────────────────────────
rpcServerReady = true;
for (const raw of queuedRpcLines.splice(0)) {
await handleRpcLine(raw);
}
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);