Improve LightRAG knowledge search locator alignment
This commit is contained in:
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user