集成 OpenHub 与 WeKnora Page AI

This commit is contained in:
Agent Board
2026-06-26 20:01:02 +08:00
parent dabaf03bd7
commit e433c07061
63 changed files with 12678 additions and 332 deletions
@@ -3,8 +3,8 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
KnowledgeRagStatusQuery,
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
};
use axum::extract::{Extension, Json, Query, State};
use serde_json::{json, Value};
@@ -36,22 +36,37 @@ pub async fn status(
Ok(payload)
}
pub async fn search(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_weknora_search_payload_invalid",
format!("WeKnora 检索参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::search(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(payload)
}
pub async fn query(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
inject_identity_args(&mut args, input);
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
@@ -74,16 +89,8 @@ pub async fn open_reference(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -107,16 +114,7 @@ pub async fn section_context(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
inject_identity_args(&mut args, input);
let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -134,6 +132,109 @@ pub async fn section_context(
Ok(compact_section_context_for_agent(payload))
}
pub async fn list_sources(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
let payload = status(state, context, input).await?;
Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.weknora.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
"registry": payload.get("registry").cloned().unwrap_or(Value::Null),
"documents": payload.get("documents").cloned().unwrap_or(Value::Null),
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.",
}))
}
pub async fn get_source_status(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
let payload = list_sources(state, context, input).await?;
let requested_source = args
.get("sourcePath")
.or_else(|| args.get("providerKnowledgeId"))
.or_else(|| args.get("providerSourceId"))
.and_then(Value::as_str)
.unwrap_or_default();
let entries = payload
.pointer("/registry/entries")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let matched = entries
.into_iter()
.filter(|entry| {
requested_source.is_empty()
|| entry
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.is_some_and(|value| value == requested_source)
|| entry
.get("sourceId")
.and_then(Value::as_str)
.is_some_and(|value| value == requested_source)
})
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"schema": "mnote.weknora.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
"sources": matched,
"requestedSource": requested_source,
}))
}
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) {
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
}
fn ensure_weknora_scope(
args: &Value,
input: &ToolCallInput,
context: &RequestContext,
) -> Result<(), WebError> {
let has_root_uri = args
.get("rootUri")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
|| input.effective_root_uri().is_some();
let has_scope = args.get("scope").is_some()
|| args.get("allowlist").is_some()
|| args.get("allowedRoots").is_some()
|| args.get("aiAccessScope").is_some()
|| args
.get("sourcePaths")
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty());
if has_root_uri && has_scope {
return Ok(());
}
Err(WebError::bad_request_code(
"mnote_weknora_scope_required",
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
)
.with_context(context))
}
fn compact_query_result_for_agent(payload: Value) -> Value {
let references = payload
.get("references")
@@ -181,7 +282,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"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. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. 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,
@@ -351,7 +452,7 @@ fn compact_section_context_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
@@ -382,7 +483,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.unwrap_or_else(|| quote_diagnostics(&quote));
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")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"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),
@@ -416,7 +517,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
.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")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"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),
@@ -490,7 +591,7 @@ mod tests {
fn compact_query_result_marks_post_filter_scope_without_raw_chunks() {
let payload = json!({
"ok": true,
"provider": "lightrag",
"provider": "lightrag_legacy",
"sourceScope": ["docs/a.md"],
"sourceScopeMode": "post_filter_mapped_references",
"rawScopeFiltered": false,
@@ -522,7 +623,9 @@ mod tests {
assert!(compact.get("chunks").is_none());
assert_eq!(
compact["rawMetadataMeaning"].as_str(),
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
Some(
"provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote"
)
);
assert!(compact["answerGuidance"]
.as_str()
@@ -57,6 +57,10 @@ fn doc_tools() -> Vec<Value> {
fn knowledge_rag_tools() -> Vec<Value> {
vec![
knowledge_rag_status_tool(),
weknora_search_tool(),
weknora_list_sources_tool(),
weknora_get_source_status_tool(),
weknora_open_reference_tool(),
knowledge_rag_query_tool(),
knowledge_rag_section_context_tool(),
knowledge_rag_open_reference_tool(),
@@ -355,7 +359,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnoraLightRAG 仅作为 legacy fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -367,6 +371,114 @@ fn knowledge_rag_status_tool() -> Value {
})
}
fn weknora_scope_properties() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("scope".into(), json!({ "type": "object" }));
map.insert(
"allowlist".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
map.insert("allowedRoots".into(), json!({ "type": "array" }));
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
map.insert(
"sourcePaths".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
}
properties
}
fn weknora_search_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("query".into(), json!({ "type": "string" }));
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
map.insert(
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
}
json!({
"name": "mnote.weknora.search",
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn weknora_list_sources_tool() -> Value {
json!({
"name": "mnote.weknora.list_sources",
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": weknora_scope_properties()
}
})
}
fn weknora_get_source_status_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("sourcePath".into(), json!({ "type": "string" }));
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerSourceId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.get_source_status",
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": properties
}
})
}
fn weknora_open_reference_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("reference".into(), json!({ "type": "object" }));
map.insert(
"providerKnowledgeBaseId".into(),
json!({ "type": "string" }),
);
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerChunkId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.open_reference",
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "reference"],
"properties": properties
}
})
}
fn knowledge_rag_query_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -391,7 +503,7 @@ fn knowledge_rag_query_tool() -> Value {
json!({
"type": "boolean",
"default": false,
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
"description": "返回由 provider sidecar/headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
}),
);
map.insert(
@@ -399,13 +511,13 @@ fn knowledge_rag_query_tool() -> Value {
json!({
"type": "array",
"items": { "type": "string" },
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 referencesprovider raw 仍可能是全局结果。"
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后,MNote 只过滤返回的 referencesprovider raw 仍可能是全局结果。"
}),
);
}
json!({
"name": "mnote.knowledge_rag.query",
"description": " LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnoraLightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -429,7 +541,7 @@ fn knowledge_rag_open_reference_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.open_reference",
"description": "LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
"description": "provider reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -474,7 +586,7 @@ fn knowledge_rag_section_context_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.section_context",
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
"description": "按 documentStructureIndex section 的 block/paragraph range,从 provider sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -38,8 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "资料库问答",
description:
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -47,6 +46,10 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.weknora.search",
"mnote.weknora.list_sources",
"mnote.weknora.get_source_status",
"mnote.weknora.open_reference",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",