Files
mnote/rust/crates/mnote-web/src/routes/knowledge_rag.rs
T

5167 lines
186 KiB
Rust
Raw Normal View History

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::evidence::{citation_markdown_for_locator, citation_url_for_locator};
use crate::routes::local_folder_source;
use axum::extract::{Extension, Json, Query, State};
use axum::http::StatusCode;
use core_protocol::evidence::{
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceResourceKind,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const REGISTRY_SCHEMA: &str = "mnote.knowledge_rag.source_registry.v1";
const REFERENCE_SCHEMA: &str = "mnote.knowledge_rag.reference.v1";
const DEFAULT_LIGHTRAG_ENDPOINT: &str = "http://127.0.0.1:9621";
const DEFAULT_LIGHTRAG_INPUT_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/inputs";
const DEFAULT_LIGHTRAG_WORKING_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/rag_storage";
const MAX_INGEST_SOURCES_PER_REQUEST: usize = 200;
const LIGHTRAG_PROVIDER_MIN_QUERY_CHARS: usize = 2;
2026-06-07 10:35:21 +08:00
const SOURCE_SCOPE_MODE_POST_FILTER: &str = "post_filter_mapped_references";
const KNOWLEDGE_RAG_SOURCE_EXTENSIONS: &[&str] = &[
"md", "markdown", "txt", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "csv", "png",
"jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff",
];
const LIGHTRAG_SCAN_SOURCE_EXTENSIONS: &[&str] = &[
"md", "markdown", "mdx", "txt", "pdf", "docx", "pptx", "xlsx", "rtf", "odt", "tex", "epub",
"html", "htm", "png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff",
];
#[derive(Debug, Clone, Default)]
struct CitationTextBundle {
raw_quote: String,
display_quote: String,
locator_evidence_text: String,
search_query: String,
normalized_fingerprint: String,
locator_text_source: &'static str,
display_cleaned: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagStatusQuery {
pub workspace_id: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagIngestRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub sources: Vec<KnowledgeRagSourceInput>,
pub force: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagSourceInput {
pub source_path: Option<String>,
pub path: Option<String>,
pub parser_hint: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagQueryRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub question: Option<String>,
pub query: Option<String>,
pub mode: Option<String>,
pub top_k: Option<u32>,
pub chunk_top_k: Option<u32>,
pub include_chunk_content: Option<bool>,
pub source_paths: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagSearchRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub query: String,
pub mode: Option<String>,
pub top_k: Option<u32>,
pub chunk_top_k: Option<u32>,
pub include_chunk_content: Option<bool>,
pub source_paths: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagOpenReferenceRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub reference: Option<Value>,
pub reference_id: Option<String>,
pub file_path: Option<String>,
pub chunk_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagDeleteSourceRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub source_path: Option<String>,
pub light_rag_doc_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagPruneRegistryRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct KnowledgeRagSourceRegistry {
schema: String,
workspace_id: String,
root_uri: String,
updated_at_ms: u128,
#[serde(default)]
indexed_roots: Vec<KnowledgeRagIndexedRoot>,
entries: Vec<KnowledgeRagSourceRegistryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct KnowledgeRagIndexedRoot {
root_relative_path: String,
recursive: bool,
#[serde(default)]
exclude_patterns: Vec<String>,
run_on_change: Option<bool>,
updated_at_ms: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KnowledgeRagSourceRegistryEntry {
source_id: String,
workspace_id: String,
root_uri: String,
source_path: String,
source_root_relative_path: String,
source_hash: String,
light_rag_doc_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
light_rag_status: Option<String>,
light_rag_file_path: String,
symlink_path: String,
parser_hint: Option<String>,
indexed_at_ms: Option<u128>,
deleted_at_ms: Option<u128>,
stale: bool,
updated_at_ms: u128,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct KnowledgeRagSourceStatuses {
pub(crate) indexed_paths: BTreeSet<String>,
pub(crate) indexing_paths: BTreeSet<String>,
pub(crate) failed_paths: BTreeSet<String>,
}
pub(crate) fn knowledge_rag_source_statuses(
root_path: &Path,
workspace_id: &str,
root_uri: &str,
) -> Result<KnowledgeRagSourceStatuses, WebError> {
let registry = read_registry(root_path, workspace_id, root_uri)?;
let mut statuses = KnowledgeRagSourceStatuses::default();
for entry in registry.entries {
let path = entry.source_root_relative_path.trim();
if path.is_empty() {
continue;
}
let provider_status = entry.light_rag_status.as_deref().unwrap_or_default();
if provider_status == "delete_completed" {
continue;
}
if provider_status == "failed" {
statuses.failed_paths.insert(path.to_string());
2026-06-07 10:35:21 +08:00
} else if provider_status == "delete_retry_required" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_submitted"
|| (entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some())
{
statuses.indexing_paths.insert(path.to_string());
} else if entry.stale {
statuses.failed_paths.insert(path.to_string());
} else if entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_none() {
continue;
} else if entry.indexed_at_ms.is_some() && entry.light_rag_doc_id.is_some() {
statuses.indexed_paths.insert(path.to_string());
} else {
statuses.indexing_paths.insert(path.to_string());
}
}
Ok(statuses)
}
pub async fn retired_local_ocr_endpoint(
Extension(context): Extension<RequestContext>,
) -> (StatusCode, Json<Value>) {
(
StatusCode::GONE,
Json(json!({
"ok": false,
"code": "mnote_local_ocr_retired",
"message": "本地 OCR sidecar 已退役;图片、PDF、Office 与索引统一交给 LightRAG 资料库处理。",
"replacement": {
"provider": "lightrag",
"status": "/api/knowledge-rag/status",
"ingest": "/api/knowledge-rag/ingest",
"deleteSource": "/api/knowledge-rag/delete-source"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
}
pub async fn status(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KnowledgeRagStatusQuery>,
) -> Result<Json<Value>, WebError> {
let (registry, registry_diagnostics) =
if let Some(root_uri) = query.root_uri.as_deref().filter(|value| !value.is_empty()) {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(query.workspace_id.as_deref(), root_uri);
let mut registry = read_registry(&root_path, &workspace_id, root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let diagnostics = knowledge_rag_source_content_diagnostics(&registry);
(Some(registry), diagnostics)
} else {
(None, Value::Array(Vec::new()))
};
let documents =
match lightrag_json(reqwest::Method::GET, "/documents", None, true, &context).await {
Ok(value) => json!({
"ok": true,
"rawStatusGroups": lightrag_document_status_group_counts(&value),
"documents": lightrag_document_summaries(&value),
}),
Err(error) => json!({
"ok": false,
"code": error.code(),
"message": error.message(),
"rawStatusGroups": {},
"documents": [],
}),
};
let pipeline = match lightrag_json(
reqwest::Method::GET,
"/documents/pipeline_status",
None,
true,
&context,
)
.await
{
Ok(value) => lightrag_pipeline_status_summary(&value),
Err(error) => json!({
"ok": false,
"code": error.code(),
"message": error.message(),
}),
};
let endpoint = lightrag_endpoint();
let health_raw = lightrag_json(reqwest::Method::GET, "/health", None, false, &context).await;
let rerank_status = health_raw
.as_ref()
.ok()
.map(lightrag_rerank_status_summary)
.unwrap_or_else(|| {
json!({
"enabled": false,
"available": false,
"binding": Value::Null,
"model": Value::Null,
"status": "unknown",
})
});
let health = match health_raw {
Ok(value) => json!({
"ok": true,
"health": value,
}),
Err(error) => json!({
"ok": false,
"code": error.code(),
"message": error.message(),
}),
};
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.provider_status.v1",
"provider": "lightrag",
"endpoint": endpoint,
"dashboardUrl": lightrag_dashboard_url(),
"inputDir": lightrag_input_dir().display().to_string(),
"health": health,
"rerank": rerank_status,
"documents": documents,
"pipeline": pipeline,
"registry": registry,
"registryDiagnostics": registry_diagnostics,
})))
}
pub(crate) async fn sync_registry_for_root(
state: &AppState,
context: &RequestContext,
root_uri: &str,
workspace_id: Option<&str>,
) -> Result<(), WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
state, context, root_uri,
)
.map_err(|error| error.with_context(context))?;
let workspace_id = effective_workspace_id(workspace_id, root_uri);
let mut registry = read_registry(&root_path, &workspace_id, root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, context).await
}
pub async fn ingest(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagIngestRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let input_dir = lightrag_input_dir();
fs::create_dir_all(&input_dir).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_input_dir_unavailable",
format!("无法创建 LightRAG input 目录: {error}"),
)
.with_context(&context)
})?;
let mut configured = Vec::new();
let mut expanded_count = 0usize;
let mut seen_sources = BTreeSet::<String>::new();
let force = body.force.unwrap_or(false);
let mut requested_sources = body.sources;
if requested_sources.is_empty() {
requested_sources = registry
.indexed_roots
.iter()
.map(|root| KnowledgeRagSourceInput {
source_path: Some(root.root_relative_path.clone()),
path: None,
parser_hint: None,
})
.collect();
}
if requested_sources.is_empty() {
return Err(WebError::bad_request_code(
"knowledge_rag_source_required",
"资料库索引缺少 sourcePath,且没有可重建的已保存索引范围",
)
.with_context(&context));
}
for source in requested_sources {
let source_path = source.source_path.or(source.path).ok_or_else(|| {
WebError::bad_request_code("knowledge_rag_source_required", "资料库索引缺少 sourcePath")
.with_context(&context)
})?;
upsert_indexed_root_for_request(&mut registry, &root_path, &source_path, &context)?;
let requested_parser_hint = normalize_parser_hint(source.parser_hint.as_deref(), &context)?;
let resolved_sources = resolve_knowledge_rag_sources(&root_path, &source_path, &context)?;
for resolved in resolved_sources {
expanded_count += 1;
if expanded_count > MAX_INGEST_SOURCES_PER_REQUEST {
return Err(WebError::bad_request_code(
"knowledge_rag_source_limit_exceeded",
format!(
"单次资料库索引最多支持 {MAX_INGEST_SOURCES_PER_REQUEST} 个文件,请缩小目录范围"
),
)
.with_context(&context));
}
let canonical = resolved.canonical_path;
let canonical_key = canonical.display().to_string();
if !seen_sources.insert(canonical_key.clone()) {
configured.push(json!({
"sourcePath": canonical_key,
"requestedSourcePath": resolved.requested_path,
"sourceKind": resolved.source_kind,
"skipped": true,
"skipReason": "duplicate_request",
}));
continue;
}
let relative = root_relative_path(&root_path, &canonical)?;
let source_hash = source_hash(&canonical)?;
let file_name = canonical
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source");
let parser_hint = requested_parser_hint
.clone()
.or_else(|| default_lightrag_parser_hint_for_source(&canonical, file_name));
let direct_scan_source = lightrag_scan_supported_file(&canonical);
if !force {
if let Some(existing) = registry.entries.iter().find(|entry| {
entry.source_path == canonical_key
&& entry.source_hash == source_hash
&& entry.deleted_at_ms.is_none()
&& !entry.stale
&& (entry.indexed_at_ms.is_some()
|| entry.light_rag_doc_id.is_some()
|| matches!(
entry.light_rag_status.as_deref(),
Some("processing" | "pending" | "submitted" | "parsed")
))
}) {
configured.push(json!({
"sourceId": existing.source_id,
"sourcePath": existing.source_path,
"sourceRootRelativePath": existing.source_root_relative_path,
"requestedSourcePath": resolved.requested_path,
"sourceKind": resolved.source_kind,
"lightRagFilePath": existing.light_rag_file_path,
"lightRagDocId": existing.light_rag_doc_id,
"lightRagStatus": existing.light_rag_status,
"skipped": true,
"skipReason": "already_registered",
}));
continue;
}
}
let staged_source = stage_lightrag_source(
&canonical,
file_name,
parser_hint.as_deref(),
&input_dir,
&context,
)?;
let light_rag_file_path = staged_source.light_rag_file_path.clone();
let symlink_path = staged_source.staged_path.clone();
let now = now_ms();
let source_id = format!(
"lightrag-source-{}",
short_hash(&canonical.display().to_string())
);
upsert_registry_entry(
&mut registry,
KnowledgeRagSourceRegistryEntry {
source_id: source_id.clone(),
workspace_id: workspace_id.clone(),
root_uri: body.root_uri.clone(),
source_path: canonical_key.clone(),
source_root_relative_path: relative.clone(),
source_hash: source_hash.clone(),
light_rag_doc_id: None,
light_rag_status: Some("submitted".into()),
light_rag_file_path: light_rag_file_path.clone(),
symlink_path: symlink_path.display().to_string(),
parser_hint: parser_hint.clone(),
indexed_at_ms: None,
deleted_at_ms: None,
stale: false,
updated_at_ms: now,
},
);
configured.push(json!({
"sourceId": source_id,
"sourcePath": canonical_key,
"sourceRootRelativePath": relative,
"requestedSourcePath": resolved.requested_path,
"sourceKind": resolved.source_kind,
"lightRagFilePath": light_rag_file_path,
"symlinkPath": symlink_path.display().to_string(),
"scanMode": if direct_scan_source { "direct" } else { "markdown_wrapper" },
"lightRagStatus": "submitted",
}));
}
}
write_registry(&root_path, &mut registry)?;
let scan = lightrag_json(
reqwest::Method::POST,
"/documents/scan",
None,
true,
&context,
)
.await?;
let scan_status = scan.get("status").and_then(Value::as_str).unwrap_or("");
let retry_required = scan_status == "scanning_skipped_pipeline_busy";
Ok(Json(json!({
"ok": !retry_required,
"schema": "mnote.knowledge_rag.ingest_result.v1",
"provider": "lightrag",
"force": body.force.unwrap_or(false),
"configuredSources": configured,
"scan": scan,
"retryRequired": retry_required,
"registry": registry,
})))
}
pub async fn query_rag(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagQueryRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let query = body.question.or(body.query).ok_or_else(|| {
WebError::bad_request_code("knowledge_rag_query_required", "资料库问答缺少 query")
.with_context(&context)
})?;
validate_lightrag_provider_query_length(&query, "knowledge_rag_query_too_short", &context)?;
let mode = normalize_lightrag_query_mode(body.mode.as_deref());
let raw = lightrag_json(
reqwest::Method::POST,
"/query/data",
Some(json!({
"query": query.clone(),
"mode": mode.clone(),
"top_k": body.top_k,
"chunk_top_k": body.chunk_top_k,
"include_references": true,
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
})),
true,
&context,
)
.await?;
let source_scope = normalize_source_scope(body.source_paths.as_deref());
let mut references =
mapped_references(&raw, &registry, &body.root_uri, &root_path, Some(&query));
filter_mapped_references_by_source_scope(&mut references, &source_scope);
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.query_result.v1",
"provider": "lightrag",
"retrievalMode": mode,
"sourceScope": source_scope,
2026-06-07 10:35:21 +08:00
"sourceScopeMode": SOURCE_SCOPE_MODE_POST_FILTER,
"rawScopeFiltered": false,
"raw": raw,
"citations": knowledge_rag_citations(&references),
"references": references,
})))
}
pub async fn search(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagSearchRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let query = body.query.trim().to_string();
if query.is_empty() {
return Err(WebError::bad_request_code(
"knowledge_rag_search_query_required",
"资料库检索缺少 query",
)
.with_context(&context));
}
validate_lightrag_provider_query_length(
&query,
"knowledge_rag_search_query_too_short",
&context,
)?;
let search_mode = normalize_knowledge_rag_search_mode(body.mode.as_deref());
let raw = if search_mode == "exact" {
lightrag_json(
reqwest::Method::POST,
"/query/search",
Some(json!({
"query": query.clone(),
"limit": body.top_k.or(body.chunk_top_k).unwrap_or(50),
"max_per_chunk": 12,
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
2026-06-09 18:40:48 +08:00
"include_sidecar": true,
})),
true,
&context,
)
.await?
} else {
lightrag_json(
reqwest::Method::POST,
"/query/data",
Some(json!({
"query": query.clone(),
"mode": search_mode.clone(),
"top_k": body.top_k,
"chunk_top_k": body.chunk_top_k,
"include_references": true,
"include_chunk_content": body.include_chunk_content.unwrap_or(true),
})),
true,
&context,
)
.await?
};
let source_scope = normalize_source_scope(body.source_paths.as_deref());
let mut references =
mapped_references(&raw, &registry, &body.root_uri, &root_path, Some(&query));
filter_mapped_references_by_source_scope(&mut references, &source_scope);
if search_mode == "exact" {
filter_mapped_references_by_search_query(&mut references, &query);
rank_mapped_references_for_query(&mut references, &query);
}
dedupe_mapped_references_by_locator(&mut references);
let results = references
.iter()
.enumerate()
.map(|(index, reference)| {
knowledge_rag_search_result(reference, index, &body.root_uri, &query)
})
.collect::<Vec<_>>();
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.search_results.v1",
"provider": "lightrag",
"query": query,
"retrievalMode": search_mode,
"rawRetrievalMode": raw
.get("metadata")
.and_then(|metadata| metadata.get("query_mode"))
.cloned()
.unwrap_or(Value::Null),
"sourceScope": source_scope,
"sourceScopeMode": SOURCE_SCOPE_MODE_POST_FILTER,
"rawScopeFiltered": false,
"results": results,
"citations": knowledge_rag_citations(&references),
"references": references,
"registry": {
"schema": registry.schema,
"workspaceId": registry.workspace_id,
"rootUri": registry.root_uri,
"updatedAtMs": registry.updated_at_ms,
"indexedRoots": registry.indexed_roots,
"sourceCount": registry.entries.len(),
},
})))
}
fn knowledge_rag_search_result(
reference: &Value,
index: usize,
root_uri: &str,
query: &str,
) -> Value {
let source_path = reference
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.or_else(|| reference.get("filePath").and_then(Value::as_str))
.unwrap_or_default();
let title = Path::new(source_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(source_path);
let chunk_id = reference
.get("chunkId")
.and_then(Value::as_str)
.or_else(|| {
reference
.get("chunkId")
.and_then(|value| value.get("id"))
.and_then(Value::as_str)
})
.unwrap_or_default();
let source_id = reference
.get("sourceId")
.and_then(Value::as_str)
.unwrap_or_default();
let id = if !source_id.is_empty() || !chunk_id.is_empty() {
format!("knowledge-rag:{source_id}:{chunk_id}")
} else {
format!("knowledge-rag:reference:{index}")
};
let quote = reference_display_quote(reference);
let locator_precision = locator_precision_for_reference(reference);
json!({
"id": id,
"documentId": id,
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
"title": title,
"path": source_path,
"resourceType": resource_type_for_path(source_path),
"sourceKind": "local_folder",
"rootUri": root_uri,
"snippet": quote.chars().take(220).collect::<String>(),
"quote": quote,
"rawQuote": reference.get("rawQuote").cloned().unwrap_or(Value::Null),
"displayQuote": reference.get("displayQuote").cloned().unwrap_or_else(|| json!(quote)),
"locatorEvidenceText": reference.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
"normalizedFingerprint": reference.get("normalizedFingerprint").cloned().unwrap_or(Value::Null),
"citationDiagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
"matchSource": reference.get("matchSource").and_then(Value::as_str).unwrap_or("lightrag_reference"),
"occurrenceIndex": reference.get("occurrenceIndex").cloned().unwrap_or(Value::Null),
"provider": "lightrag",
"query": query,
"hasOcr": true,
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or_else(|| json!(true)),
"locatorPrecision": locator_precision,
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"publicPath": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
"openAction": reference.get("openAction").cloned().unwrap_or(Value::Null),
"source": {
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"reference": reference,
}
})
}
fn knowledge_rag_citations(references: &[Value]) -> Vec<Value> {
references
.iter()
.enumerate()
.filter_map(|(index, reference)| knowledge_rag_citation(reference, index))
.collect()
}
fn knowledge_rag_citation(reference: &Value, index: usize) -> Option<Value> {
let citation_url = reference.get("citationUrl").cloned().unwrap_or(Value::Null);
if citation_url.is_null() && reference.get("locator").is_none_or(Value::is_null) {
return None;
}
let citation_id = reference
.get("citationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
short_citation_id(
reference
.get("sourceId")
.and_then(Value::as_str)
.unwrap_or_default(),
reference
.get("chunkId")
.and_then(Value::as_str)
.unwrap_or_default(),
reference
.get("locator")
.and_then(|locator| locator.get("blockId"))
.and_then(Value::as_str)
.unwrap_or_default(),
index as u64,
reference
.get("normalizedFingerprint")
.and_then(Value::as_str)
.unwrap_or_default(),
)
});
Some(json!({
"schema": "mnote.knowledge_rag.citation.v1",
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"citationId": citation_id.clone(),
"citationLabel": format!("[{}]", citation_id),
"sourceId": reference.get("sourceId").cloned().unwrap_or(Value::Null),
"sourcePath": reference.get("sourcePath").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagFilePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
"lightRagChunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
"blockId": reference
.get("locator")
.and_then(|locator| locator.get("blockId"))
.cloned()
.unwrap_or(Value::Null),
"headingPath": reference.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"rawQuote": reference.get("rawQuote").cloned().unwrap_or(Value::Null),
"displayQuote": reference.get("displayQuote").cloned().unwrap_or_else(|| json!(reference_display_quote(reference))),
"locatorEvidenceText": reference.get("locatorEvidenceText").cloned().unwrap_or_else(|| json!(reference_locator_evidence_text(reference))),
"searchQuery": reference.get("searchQuery").cloned().unwrap_or(Value::Null),
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or_else(|| json!(locator_precision_for_reference(reference))),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or_else(|| json!(true)),
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"citationUrl": citation_url,
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"relevanceScore": reference.get("relevanceScore").cloned().unwrap_or(Value::Null),
"diagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
}))
}
fn heading_path_for_sidecar_block(block: Option<&Value>) -> Vec<String> {
let Some(block) = block else {
return Vec::new();
};
let mut headings = block
.get("parent_headings")
.or_else(|| block.get("parentHeadings"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(clean_lightrag_text_for_display)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(heading) = block
.get("heading")
.and_then(Value::as_str)
.map(clean_lightrag_text_for_display)
.filter(|value| !value.is_empty())
{
if headings.last().is_none_or(|last| last != &heading) {
headings.push(heading);
}
}
headings
}
fn locator_precision_for_reference(reference: &Value) -> &'static str {
let locator = reference.get("locator").unwrap_or(&Value::Null);
locator_precision_for_locator_value(locator)
}
fn locator_precision_for_locator_value(locator: &Value) -> &'static str {
if locator.is_null() {
return "file";
}
let has_bbox = locator.get("bbox").is_some_and(|value| !value.is_null());
let has_page = locator
.get("page")
.and_then(Value::as_u64)
.is_some_and(|page| page > 0);
if has_bbox && has_page {
return "bbox";
}
if has_page {
return "page";
}
let has_block = locator
.get("blockId")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
if has_block {
return "paragraph";
}
"file"
}
fn locator_precision_for_locator(locator: &EvidenceLocator) -> &'static str {
if locator.page.is_some() && locator.bbox.is_some() {
return "bbox";
}
if locator.page.is_some() {
return "page";
}
if locator
.block_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return "paragraph";
}
"file"
}
fn non_whitespace_char_count(query: &str) -> usize {
query.chars().filter(|ch| !ch.is_whitespace()).count()
}
fn validate_lightrag_provider_query_length(
query: &str,
code: &'static str,
context: &RequestContext,
) -> Result<(), WebError> {
if non_whitespace_char_count(query) >= LIGHTRAG_PROVIDER_MIN_QUERY_CHARS {
return Ok(());
}
Err(WebError::bad_request_code(code, "请输入至少 2 个字再搜索").with_context(context))
}
fn normalize_source_scope(source_paths: Option<&[String]>) -> Vec<String> {
source_paths
.unwrap_or(&[])
.iter()
.map(|value| value.trim().trim_matches('/').replace('\\', "/"))
.filter(|value| !value.is_empty() && value != ".")
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
fn filter_mapped_references_by_source_scope(references: &mut Vec<Value>, source_scope: &[String]) {
if source_scope.is_empty() {
return;
}
references.retain(|reference| {
let source_path = reference
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.unwrap_or_default()
.trim_matches('/')
.replace('\\', "/");
source_scope
.iter()
.any(|scope| source_path == *scope || source_path.starts_with(&format!("{scope}/")))
});
}
fn filter_mapped_references_by_search_query(references: &mut Vec<Value>, query: &str) {
let query_normalized = normalize_text_for_match(query).to_ascii_lowercase();
if query_normalized.is_empty() {
return;
}
references
.retain(|reference| mapped_reference_matches_search_query(reference, &query_normalized));
}
fn mapped_reference_matches_search_query(reference: &Value, query_normalized: &str) -> bool {
let quote = reference_display_quote(reference);
let source_path = reference
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.unwrap_or_default();
let combined =
normalize_text_for_match(&format!("{source_path}\n{quote}")).to_ascii_lowercase();
if combined.contains(query_normalized) {
return true;
}
let terms = query_match_terms(query_normalized);
if query_normalized.chars().any(is_cjk_char) {
if query_is_single_cjk_lookup(query_normalized) {
return false;
}
return terms
.iter()
.filter(|term| significant_cjk_query_term(term))
.any(|term| block_matches_query_term(&combined, term));
}
!terms.is_empty()
&& terms
.iter()
.all(|term| block_matches_query_term(&combined, term))
}
fn dedupe_mapped_references_by_locator(references: &mut Vec<Value>) {
let mut seen = BTreeSet::<String>::new();
references.retain(|reference| {
let source = reference
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.or_else(|| reference.get("filePath").and_then(Value::as_str))
.unwrap_or_default();
let block_id = reference
.get("locator")
.and_then(|locator| locator.get("blockId"))
.and_then(Value::as_str)
.unwrap_or_default();
let chunk_id = reference
.get("chunkId")
.and_then(Value::as_str)
.unwrap_or_default();
let fingerprint = reference
.get("normalizedFingerprint")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| normalized_quote_fingerprint(reference_display_quote(reference)));
let key = if !block_id.is_empty() {
format!("{source}\nblock:{block_id}")
} else if !chunk_id.is_empty() {
format!("{source}\nchunk:{chunk_id}\nfingerprint:{fingerprint}")
} else {
format!("{source}\nquote:{fingerprint}")
};
seen.insert(key)
});
}
fn rank_mapped_references_for_query(references: &mut Vec<Value>, query: &str) {
let mut indexed = references
.drain(..)
.enumerate()
.map(|(index, reference)| {
let score = reference_query_score(&reference, query);
(index, score, reference)
})
.collect::<Vec<_>>();
indexed.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
references.extend(indexed.into_iter().map(|(_, _, reference)| reference));
}
fn reference_query_score(reference: &Value, query: &str) -> i64 {
let query_normalized = normalize_text_for_match(query).to_ascii_lowercase();
if query_normalized.is_empty() {
return 0;
}
let tokens = query_match_terms(&query_normalized);
let source_path = reference
.get("sourceRootRelativePath")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let file_path = reference
.get("filePath")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let quote = reference_display_quote(reference).to_ascii_lowercase();
let citation = reference
.get("citationMarkdown")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let mut score = 0;
if quote.contains(&query_normalized) {
score += 100;
}
if source_path.contains(&query_normalized) || file_path.contains(&query_normalized) {
score += 60;
}
for token in tokens {
if source_path.contains(&token) {
score += 20;
}
if file_path.contains(&token) {
score += 12;
}
if quote.contains(&token) {
score += 8;
}
if citation.contains(&token) {
score += 4;
}
}
score
}
fn normalize_lightrag_query_mode(mode: Option<&str>) -> String {
match mode
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.as_deref()
{
Some("local") => "local".into(),
Some("global") => "global".into(),
Some("hybrid") => "hybrid".into(),
Some("naive") => "naive".into(),
Some("mix") => "mix".into(),
Some("bypass") => "bypass".into(),
Some("exact" | "keyword" | "full_text" | "full-text") => "naive".into(),
Some(_) => "mix".into(),
None => "mix".into(),
}
}
fn normalize_knowledge_rag_search_mode(mode: Option<&str>) -> String {
match mode
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.as_deref()
{
Some("exact" | "keyword" | "keywords" | "full_text" | "full-text") => "exact".into(),
Some("local") => "local".into(),
Some("global") => "global".into(),
Some("hybrid") => "hybrid".into(),
Some("naive" | "vector") => "naive".into(),
Some("mix" | "mixed") => "mix".into(),
Some(_) => "mix".into(),
None => "exact".into(),
}
}
pub async fn open_reference(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagOpenReferenceRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let file_path = body
.file_path
.or_else(|| {
body.reference
.as_ref()
.and_then(|value| value.get("file_path"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.ok_or_else(|| {
WebError::bad_request_code(
"knowledge_rag_reference_file_path_required",
"资料库引用打开缺少 filePath",
)
.with_context(&context)
})?;
let reference = map_reference_plan(
&json!({
"reference_id": body.reference_id,
"file_path": file_path,
"chunk_id": body.chunk_id,
}),
&registry,
&body.root_uri,
&root_path,
None,
);
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.open_reference_result.v1",
"reference": reference,
"registry": registry,
})))
}
pub async fn delete_source(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagDeleteSourceRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let source_path = body.source_path.as_deref().map(str::trim);
let doc_id = body.light_rag_doc_id.as_deref().map(str::trim);
let now = now_ms();
let mut matched_doc_ids = Vec::new();
for entry in &mut registry.entries {
let source_matches = source_path
.filter(|value| !value.is_empty())
.is_some_and(|value| {
value == entry.source_path || value == entry.source_root_relative_path
});
let doc_matches = doc_id
.filter(|value| !value.is_empty())
.is_some_and(|value| entry.light_rag_doc_id.as_deref() == Some(value));
if source_matches || doc_matches {
if let Some(doc_id) = entry.light_rag_doc_id.clone() {
matched_doc_ids.push(doc_id);
}
entry.stale = true;
entry.deleted_at_ms = Some(now);
entry.light_rag_status = Some(
if entry.light_rag_doc_id.is_some() {
"delete_submitted"
} else {
"delete_completed"
}
.into(),
);
entry.updated_at_ms = now;
}
}
matched_doc_ids.sort();
matched_doc_ids.dedup();
let delete_result = if matched_doc_ids.is_empty() {
json!({
"status": "no_lightrag_doc",
"message": "没有可删除的 LightRAG doc idregistry 已按 source 标记 stale",
})
} else {
lightrag_json(
reqwest::Method::DELETE,
"/documents/delete_document",
Some(json!({
"doc_ids": matched_doc_ids,
"delete_file": false,
"delete_llm_cache": false,
})),
true,
&context,
)
.await?
};
write_registry(&root_path, &mut registry)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.delete_source_result.v1",
"provider": "lightrag",
"deleteResult": delete_result,
"registry": registry,
})))
}
pub async fn prune_registry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagPruneRegistryRequest>,
) -> Result<Json<Value>, WebError> {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
sync_registry_with_documents(&root_path, &mut registry, &context).await?;
let before = registry.entries.len();
registry
.entries
.retain(|entry| !knowledge_rag_registry_entry_prunable(entry));
let removed = before.saturating_sub(registry.entries.len());
write_registry(&root_path, &mut registry)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.prune_registry_result.v1",
"provider": "lightrag",
"removed": removed,
"registry": registry,
})))
}
fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
2026-06-07 10:35:21 +08:00
if matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) {
return false;
}
if entry.light_rag_doc_id.is_some() && (entry.deleted_at_ms.is_some() || entry.stale) {
return false;
}
entry.deleted_at_ms.is_some()
|| entry.stale
|| matches!(
entry.light_rag_status.as_deref(),
Some("delete_completed" | "failed")
)
}
2026-06-07 10:35:21 +08:00
fn knowledge_rag_provider_delete_confirmed(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
entry.light_rag_doc_id.is_some()
&& (entry.deleted_at_ms.is_some()
|| entry.stale
|| matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
))
}
fn mark_registry_entry_delete_completed(entry: &mut KnowledgeRagSourceRegistryEntry, now: u128) {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
}
async fn sync_registry_with_documents(
root_path: &Path,
registry: &mut KnowledgeRagSourceRegistry,
context: &RequestContext,
) -> Result<(), WebError> {
if registry.entries.is_empty() {
return Ok(());
}
let docs = lightrag_json(reqwest::Method::GET, "/documents", None, true, context).await?;
let by_file_path = lightrag_documents_by_file_path(&docs);
let now = now_ms();
let mut changed = false;
2026-06-07 10:35:21 +08:00
let mut retry_doc_ids = Vec::new();
for entry in &mut registry.entries {
if let Some(doc) = document_for_registry_entry(&by_file_path, entry) {
if let Some(id) = doc.get("id").and_then(Value::as_str) {
entry.light_rag_doc_id = Some(id.to_string());
}
2026-06-07 10:35:21 +08:00
let delete_pending = matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) || entry.deleted_at_ms.is_some();
if delete_pending {
if let Some(doc_id) = entry.light_rag_doc_id.clone() {
retry_doc_ids.push(doc_id);
}
if entry.light_rag_status.is_none() {
entry.light_rag_status = Some("delete_submitted".into());
}
} else if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(status.to_string());
}
2026-06-07 10:35:21 +08:00
if !entry.stale
&& entry.deleted_at_ms.is_none()
&& doc.get("status").and_then(Value::as_str) == Some("processed")
{
entry.indexed_at_ms.get_or_insert(now);
entry.stale = !Path::new(&entry.source_path).exists();
}
entry.updated_at_ms = now;
changed = true;
2026-06-07 10:35:21 +08:00
} else if knowledge_rag_provider_delete_confirmed(entry) {
mark_registry_entry_delete_completed(entry, now);
changed = true;
} else if entry.deleted_at_ms.is_some()
&& entry.light_rag_doc_id.is_none()
&& entry.light_rag_status.as_deref() != Some("delete_completed")
{
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
changed = true;
} else if !Path::new(&entry.source_path).exists() {
entry.stale = true;
entry.updated_at_ms = now;
changed = true;
}
}
2026-06-07 10:35:21 +08:00
let mut stale_doc_ids = sync_registry_source_state(registry, now)?;
stale_doc_ids.extend(retry_doc_ids);
stale_doc_ids.sort();
stale_doc_ids.dedup();
if !stale_doc_ids.is_empty() {
2026-06-07 10:35:21 +08:00
let delete_result = lightrag_json(
reqwest::Method::DELETE,
"/documents/delete_document",
Some(json!({
"doc_ids": stale_doc_ids,
"delete_file": false,
"delete_llm_cache": false,
})),
true,
context,
)
.await;
2026-06-07 10:35:21 +08:00
for entry in &mut registry.entries {
if entry
.light_rag_doc_id
.as_deref()
.is_some_and(|doc_id| stale_doc_ids.iter().any(|item| item == doc_id))
{
entry.light_rag_status = Some(
if delete_result.is_err() {
"delete_retry_required"
} else {
"delete_submitted"
}
.into(),
);
entry.updated_at_ms = now;
}
}
changed = true;
}
if changed {
write_registry(root_path, registry)?;
}
Ok(())
}
fn lightrag_document_status_group_counts(docs: &Value) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for (status, items) in docs
.get("statuses")
.and_then(Value::as_object)
.into_iter()
.flat_map(|map| map.iter())
{
counts.insert(status.clone(), items.as_array().map_or(0, Vec::len));
}
counts
}
fn lightrag_document_summaries(docs: &Value) -> Vec<Value> {
let mut items = Vec::new();
for (status_group, doc) in docs
.get("statuses")
.and_then(Value::as_object)
.into_iter()
.flat_map(|map| map.iter())
.flat_map(|(status, items)| {
items
.as_array()
.into_iter()
.flat_map(move |docs| docs.iter().map(move |doc| (status, doc)))
})
{
items.push(json!({
"id": doc.get("id").and_then(Value::as_str).unwrap_or_default(),
"filePath": doc.get("file_path").and_then(Value::as_str).unwrap_or_default(),
"status": doc.get("status").and_then(Value::as_str).unwrap_or(status_group),
"statusGroup": status_group,
"summary": doc.get("summary").and_then(Value::as_str).unwrap_or_default(),
"chunksCount": doc.get("chunks_count").or_else(|| doc.get("chunks")).cloned().unwrap_or(Value::Null),
"createdAt": doc.get("created_at").or_else(|| doc.get("created")).cloned().unwrap_or(Value::Null),
"updatedAt": doc.get("updated_at").or_else(|| doc.get("updated")).cloned().unwrap_or(Value::Null),
}));
}
items
}
fn lightrag_pipeline_status_summary(value: &Value) -> Value {
let latest_message = value
.get("latest_message")
.and_then(Value::as_str)
.unwrap_or_default();
let progress = parse_lightrag_chunk_progress(latest_message);
json!({
"ok": true,
"busy": value.get("busy").and_then(Value::as_bool).unwrap_or(false),
"scanning": value.get("scanning").and_then(Value::as_bool).unwrap_or(false),
"jobName": value.get("job_name").and_then(Value::as_str).unwrap_or_default(),
"latestMessage": latest_message,
"cancellationRequested": value.get("cancellation_requested").and_then(Value::as_bool).unwrap_or(false),
"cancellationReason": value.get("cancellation_reason").cloned().unwrap_or(Value::Null),
"progress": progress.map(|progress| json!({
"current": progress.current,
"total": progress.total,
"docId": progress.doc_id,
})).unwrap_or(Value::Null),
})
}
fn lightrag_rerank_status_summary(value: &Value) -> Value {
let configuration = value.get("configuration").unwrap_or(&Value::Null);
let queue = value.get("rerank_queue_status").unwrap_or(&Value::Null);
let enabled = configuration
.get("enable_rerank")
.and_then(Value::as_bool)
.unwrap_or(false);
let available = queue
.get("available")
.and_then(Value::as_bool)
.unwrap_or(false);
let binding = configuration
.get("rerank_binding")
.cloned()
.unwrap_or(Value::Null);
let model = configuration
.get("rerank_model")
.cloned()
.unwrap_or(Value::Null);
json!({
"enabled": enabled,
"available": available,
"providerRerankEnabled": enabled,
"providerRerankAvailable": available,
"rerankModel": model.clone(),
"binding": binding,
"model": model,
"minScore": configuration.get("min_rerank_score").cloned().unwrap_or(Value::Null),
"status": if enabled && available { "available" } else if enabled { "unavailable" } else { "disabled" },
"implementation": "provider_status_only",
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LightRagChunkProgress {
current: u64,
total: u64,
doc_id: String,
}
fn parse_lightrag_chunk_progress(message: &str) -> Option<LightRagChunkProgress> {
let rest = message.trim().strip_prefix("Chunk ")?;
let (current, rest) = rest.split_once(" of ")?;
let current = current.trim().parse::<u64>().ok()?;
let (total, _) = rest.split_once(' ')?;
let total = total.trim().parse::<u64>().ok()?;
let doc_token = message
.split_whitespace()
.find(|token| token.starts_with("doc-") && token.contains("-chunk-"))?;
let doc_id = doc_token
.rsplit_once("-chunk-")
.map(|(doc_id, _)| doc_id)
.unwrap_or(doc_token)
.to_string();
if total == 0 || current == 0 || doc_id.is_empty() {
return None;
}
Some(LightRagChunkProgress {
current: current.min(total),
total,
doc_id,
})
}
fn lightrag_documents_by_file_path(docs: &Value) -> BTreeMap<String, Value> {
let mut by_file_path = BTreeMap::<String, Value>::new();
for doc in docs
.get("statuses")
.and_then(Value::as_object)
.into_iter()
.flat_map(|map| map.values())
.filter_map(Value::as_array)
.flat_map(|items| items.iter())
{
if let Some(file_path) = doc.get("file_path").and_then(Value::as_str) {
by_file_path.insert(file_path.to_string(), doc.clone());
}
}
by_file_path
}
fn sync_registry_source_state(
registry: &mut KnowledgeRagSourceRegistry,
now: u128,
) -> Result<Vec<String>, WebError> {
let mut stale_doc_ids = Vec::new();
for entry in &mut registry.entries {
if entry.deleted_at_ms.is_some() {
continue;
}
let Some(doc_id) = entry.light_rag_doc_id.clone() else {
continue;
};
let source_path = Path::new(&entry.source_path);
if !source_path.exists() {
entry.stale = true;
entry.deleted_at_ms = Some(now);
entry.indexed_at_ms = None;
2026-06-07 10:35:21 +08:00
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
continue;
}
let current_hash = source_hash(source_path)?;
if current_hash != entry.source_hash {
entry.stale = true;
entry.source_hash = current_hash;
entry.indexed_at_ms = None;
2026-06-07 10:35:21 +08:00
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
}
}
stale_doc_ids.sort();
stale_doc_ids.dedup();
Ok(stale_doc_ids)
}
async fn lightrag_json(
method: reqwest::Method,
path: &str,
body: Option<Value>,
use_api_key: bool,
context: &RequestContext,
) -> Result<Value, WebError> {
let endpoint = lightrag_endpoint();
let url = format!("{}{}", endpoint.trim_end_matches('/'), path);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(180))
.build()
.map_err(|error| {
WebError::internal(format!("LightRAG HTTP client 初始化失败: {error}"))
.with_context(context)
})?;
2026-06-09 18:40:48 +08:00
let api_key = use_api_key.then(lightrag_api_key).flatten();
for attempt in 0..2 {
let mut request = client.request(method.clone(), &url);
if let Some(api_key) = api_key.as_deref() {
request = request.header("X-API-Key", api_key);
}
2026-06-09 18:40:48 +08:00
if let Some(body) = body.as_ref() {
request = request.json(body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_unreachable",
format!("无法访问 LightRAG provider: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
if attempt == 0 && should_retry_lightrag_provider_error(status, &text) {
tokio::time::sleep(Duration::from_millis(700)).await;
continue;
}
return Err(WebError::bad_gateway_code(
"knowledge_rag_lightrag_error",
format!("LightRAG provider 返回 HTTP {status}: {text}"),
)
.with_context(context));
}
return serde_json::from_str(&text).map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_json_invalid",
format!("LightRAG provider 返回非 JSON 响应: {error}"),
)
.with_context(context)
});
}
2026-06-09 18:40:48 +08:00
unreachable!("lightrag retry loop returns on every branch")
}
fn should_retry_lightrag_provider_error(status: StatusCode, body: &str) -> bool {
status.is_server_error()
&& (body.contains("RetryError")
|| body.contains("InvalidResponseError")
|| body.contains("Received empty content"))
}
fn mapped_references(
raw: &Value,
registry: &KnowledgeRagSourceRegistry,
root_uri: &str,
root_path: &Path,
query: Option<&str>,
) -> Vec<Value> {
reference_array(raw)
.into_iter()
.flat_map(|reference| {
let enriched = enrich_reference_with_chunks(raw, &reference);
expand_reference_by_chunks(&enriched)
.into_iter()
.map(|candidate| {
map_reference_plan(&candidate, registry, root_uri, root_path, query)
})
.collect::<Vec<_>>()
})
.filter(|reference| {
!reference
.get("stale")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !reference
.get("deleted")
.and_then(Value::as_bool)
.unwrap_or(false)
2026-06-07 10:35:21 +08:00
&& !reference
.get("unmapped")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.collect()
}
fn expand_reference_by_chunks(reference: &Value) -> Vec<Value> {
let Some(chunks) = reference.get("chunks").and_then(Value::as_array) else {
return vec![reference.clone()];
};
if chunks.is_empty() {
return vec![reference.clone()];
}
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let mut candidate = reference.clone();
if let Some(map) = candidate.as_object_mut() {
map.insert("chunks".into(), Value::Array(vec![chunk.clone()]));
if let Some(chunk_id) = chunk.get("chunk_id").cloned() {
map.insert("chunk_id".into(), chunk_id);
}
map.insert("chunkIndex".into(), json!(index));
}
candidate
})
.collect()
}
fn reference_array(raw: &Value) -> Vec<Value> {
if let Some(references) = raw
.get("references")
.or_else(|| raw.get("data").and_then(|value| value.get("references")))
.and_then(Value::as_array)
{
return references.clone();
}
let mut by_key = BTreeMap::<String, Value>::new();
for chunk in chunk_array(raw) {
let file_path = chunk.get("file_path").and_then(Value::as_str).unwrap_or("");
if file_path.is_empty() {
continue;
}
let reference_id = chunk
.get("reference_id")
.and_then(Value::as_str)
.unwrap_or(file_path);
let key = format!("{file_path}\n{reference_id}");
by_key.entry(key).or_insert_with(|| {
json!({
"file_path": file_path,
"reference_id": reference_id,
"chunks": [chunk.clone()],
})
});
}
by_key.into_values().collect()
}
fn chunk_array(raw: &Value) -> Vec<&Value> {
raw.get("chunks")
.or_else(|| raw.get("data").and_then(|value| value.get("chunks")))
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn enrich_reference_with_chunks(raw: &Value, reference: &Value) -> Value {
let reference_id = reference.get("reference_id").and_then(Value::as_str);
let file_path = reference.get("file_path").and_then(Value::as_str);
let chunks = chunk_array(raw)
.into_iter()
.filter(|chunk| {
let chunk_reference_id = chunk.get("reference_id").and_then(Value::as_str);
let chunk_file_path = chunk.get("file_path").and_then(Value::as_str);
reference_id
.zip(chunk_reference_id)
.is_some_and(|(left, right)| left == right)
|| file_path
.zip(chunk_file_path)
.is_some_and(|(left, right)| left == right)
})
.cloned()
.collect::<Vec<_>>();
if chunks.is_empty() {
return reference.clone();
}
let mut enriched = reference.clone();
if let Some(map) = enriched.as_object_mut() {
map.insert("chunks".into(), Value::Array(chunks));
}
enriched
}
fn map_reference_plan(
reference: &Value,
registry: &KnowledgeRagSourceRegistry,
root_uri: &str,
root_path: &Path,
query: Option<&str>,
) -> Value {
let file_path = reference
.get("file_path")
.and_then(Value::as_str)
.unwrap_or_default();
let doc_id = reference.get("doc_id").and_then(Value::as_str);
let entry = registry.entries.iter().find(|entry| {
lightrag_file_path_matches(entry, file_path)
|| doc_id.is_some_and(|doc_id| entry.light_rag_doc_id.as_deref() == Some(doc_id))
});
let locator_degraded = entry.is_none_or(|entry| entry.stale || entry.deleted_at_ms.is_some());
let source_path = entry.map(|entry| entry.source_path.clone());
let source_root_relative_path = entry.map(|entry| entry.source_root_relative_path.clone());
let primary_chunk = reference
.get("chunks")
.and_then(Value::as_array)
.and_then(|chunks| chunks.first());
let chunk_id = reference
.get("chunk_id")
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("chunk_id")))
.cloned()
.unwrap_or(Value::Null);
2026-06-09 18:40:48 +08:00
let source_chunk_id = reference
.get("source_chunk_id")
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("source_chunk_id")))
.cloned()
.or_else(|| {
chunk_id
.as_str()
.and_then(|value| value.split_once("#match-").map(|(base, _)| json!(base)))
})
.unwrap_or(Value::Null);
let occurrence_index = reference
.get("occurrence_index")
.or_else(|| primary_chunk.and_then(|chunk| chunk.get("occurrence_index")))
.cloned()
.unwrap_or(Value::Null);
2026-06-09 18:40:48 +08:00
let chunk_sidecar =
lightrag_reference_sidecar(reference, primary_chunk, &source_chunk_id, &chunk_id);
let (quote, quote_source) = primary_chunk
.and_then(|chunk| chunk.get("content"))
.and_then(Value::as_str)
.map(|value| (value.to_owned(), "chunk"))
.or_else(|| {
2026-06-09 18:40:48 +08:00
lightrag_chunk_content_for_reference_ids(&source_chunk_id, &chunk_id)
.map(|value| (value, "kv_store"))
})
.map(|(value, source)| {
let quote = query
.and_then(|query| query_centered_quote(&value, query, 500))
.unwrap_or_else(|| value.chars().take(500).collect::<String>());
(quote, source)
})
.map_or((None, "missing"), |(value, source)| (Some(value), source));
let locator_sidecar_block = entry.and_then(|entry| {
2026-06-09 18:40:48 +08:00
find_lightrag_sidecar_block(entry, query, &occurrence_index, chunk_sidecar.as_ref())
});
let locator_sidecar_text = locator_sidecar_block
.as_ref()
.and_then(|block| block.get("content"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let heading_path = heading_path_for_sidecar_block(locator_sidecar_block.as_ref());
let text_bundle = citation_text_bundle(
quote.as_deref(),
query,
locator_sidecar_text.as_deref(),
quote_source,
);
let content_diagnostics = quote_content_diagnostics(quote.as_deref(), quote_source);
let locator = entry.and_then(|entry| {
2026-06-09 18:40:48 +08:00
lightrag_locator_for_reference(
root_path,
root_uri,
entry,
&chunk_id,
query,
Some(&text_bundle.locator_evidence_text),
&occurrence_index,
chunk_sidecar.as_ref(),
)
});
let locator_precision = locator
.as_ref()
.map(locator_precision_for_locator)
.unwrap_or("file");
let locator_degraded = locator_degraded || matches!(locator_precision, "file" | "paragraph");
let fallback_citation_url =
entry.and_then(|entry| fallback_resource_citation_url(root_path, root_uri, entry));
let citation_url = locator
.as_ref()
.map(citation_url_for_locator)
.or(fallback_citation_url);
let citation_markdown = locator
.as_ref()
.map(citation_markdown_for_locator)
.or_else(|| {
citation_url.as_ref().map(|url| {
format!(
"[来源定位降级:{}]({})",
markdown_link_label_escape(file_path),
url.replace(')', "%29")
)
})
});
let citation_id = short_citation_id(
entry
.map(|entry| entry.source_id.as_str())
.unwrap_or_default(),
chunk_id.as_str().unwrap_or_default(),
locator
.as_ref()
.and_then(|locator| locator.block_id.as_deref())
.unwrap_or_default(),
occurrence_index.as_u64().unwrap_or(0),
&text_bundle.normalized_fingerprint,
);
json!({
"schema": REFERENCE_SCHEMA,
"provider": "lightrag",
"citationId": citation_id.clone(),
"citationLabel": format!("[{}]", citation_id),
"matchSource": if occurrence_index.is_null() { "lightrag_reference" } else { "lightrag_search" },
"reference": reference,
"filePath": file_path,
"chunkId": chunk_id,
2026-06-09 18:40:48 +08:00
"sourceChunkId": source_chunk_id,
"occurrenceIndex": occurrence_index,
"rawQuote": text_bundle.raw_quote.clone(),
"displayQuote": text_bundle.display_quote.clone(),
"locatorEvidenceText": text_bundle.locator_evidence_text.clone(),
"searchQuery": text_bundle.search_query.clone(),
"normalizedFingerprint": text_bundle.normalized_fingerprint.clone(),
"headingPath": heading_path,
"quote": text_bundle.display_quote.clone(),
"quoteSource": quote_source,
"contentDiagnostics": content_diagnostics,
"citationDiagnostics": {
"quoteSource": quote_source,
"rawReferenceMapped": entry.is_some(),
"sidecarBlockMapped": locator_sidecar_text.is_some(),
"displayCleaned": text_bundle.display_cleaned,
"locatorTextSource": text_bundle.locator_text_source,
},
"locator": locator,
"locatorPrecision": locator_precision,
"citationUrl": citation_url,
"sourceId": entry.map(|entry| entry.source_id.clone()),
"sourcePath": source_path,
"sourceRootRelativePath": source_root_relative_path,
2026-06-07 10:35:21 +08:00
"unmapped": entry.is_none(),
"stale": entry.is_some_and(|entry| entry.stale),
"deleted": entry.is_some_and(|entry| entry.deleted_at_ms.is_some()),
"locatorDegraded": locator_degraded,
"openAction": {
"kind": "mnote.local_resource.open",
"params": {
"rootUri": root_uri,
"path": entry.map(|entry| entry.source_root_relative_path.clone()),
"provider": "lightrag",
"filePath": file_path,
"chunkId": chunk_id,
"occurrenceIndex": occurrence_index,
"searchQuery": query.unwrap_or_default(),
"evidenceText": text_bundle.locator_evidence_text.clone(),
"displayQuote": text_bundle.display_quote.clone(),
},
},
"citationMarkdown": citation_markdown.unwrap_or_else(|| format!("[来源定位降级:{}](#)", file_path)),
})
}
fn fallback_resource_citation_url(
root_path: &Path,
root_uri: &str,
entry: &KnowledgeRagSourceRegistryEntry,
) -> Option<String> {
if entry.stale || entry.deleted_at_ms.is_some() {
return None;
}
let owner_document_id =
fallback_owner_document_id(root_path, &entry.source_root_relative_path)?;
let mut url = format!("/documents/{owner_document_id}");
append_query_param(&mut url, "sourceKind", "local_folder");
append_query_param(&mut url, "rootUri", root_uri);
append_query_param(
&mut url,
"resourceTab",
&format!(
"resource:file:{}:{}",
root_uri, entry.source_root_relative_path
),
);
append_query_param(&mut url, "resourcePath", &entry.source_root_relative_path);
Some(url)
}
fn fallback_owner_document_id(root_path: &Path, relative_path: &str) -> Option<String> {
if is_markdown_like_path(relative_path) {
return Some(local_markdown_document_id(relative_path));
}
let source_path = Path::new(relative_path);
if let Some(parent) = source_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
let parent_path = root_path.join(parent);
if let Ok(entries) = fs::read_dir(parent_path) {
let mut candidates = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
if !path.is_file() || !is_markdown_like_path(&path.to_string_lossy()) {
return None;
}
let file_name = path.file_name()?.to_string_lossy();
if file_name.starts_with('.') {
return None;
}
let rel = parent.join(file_name.as_ref());
Some(rel.to_string_lossy().replace('\\', "/"))
})
.collect::<Vec<_>>();
candidates.sort();
if let Some(candidate) = candidates.first() {
return Some(local_markdown_document_id(candidate));
}
}
}
for root_candidate in ["README.md", "index.md"] {
if root_path.join(root_candidate).is_file() {
return Some(local_markdown_document_id(root_candidate));
}
}
None
}
fn is_markdown_like_path(value: &str) -> bool {
Path::new(value)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
})
}
fn local_markdown_document_id(relative_path: &str) -> String {
format!(
"local-md:{}",
encode_local_document_id_segment(relative_path)
)
}
fn append_query_param(url: &mut String, key: &str, value: &str) {
let value = value.trim();
if value.is_empty() {
return;
}
let separator = if url.contains('?') { '&' } else { '?' };
url.push(separator);
url.push_str(&encode_query_component(key));
url.push('=');
url.push_str(&encode_query_component(value));
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(*byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn encode_local_document_id_segment(relative_path: &str) -> String {
relative_path
.replace('\\', "/")
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
vec![byte as char]
}
b'/' => "~2F".chars().collect::<Vec<_>>(),
_ => format!("~{byte:02X}").chars().collect::<Vec<_>>(),
})
.collect()
}
fn markdown_link_label_escape(value: &str) -> String {
value.replace('[', "\\[").replace(']', "\\]")
}
fn quote_content_diagnostics(quote: Option<&str>, quote_source: &str) -> Value {
let quote = quote.unwrap_or_default();
let non_empty_lines = quote
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
let non_heading_lines = non_empty_lines
.iter()
.copied()
.filter(|line| !line.starts_with('#'))
.collect::<Vec<_>>();
let quote_only_image_placeholder = !non_heading_lines.is_empty()
&& non_heading_lines
.iter()
.all(|line| is_markdown_image_placeholder_line(line));
let ocr_text_exposed = !quote.trim().is_empty() && !quote_only_image_placeholder;
json!({
"quoteEmpty": quote.trim().is_empty(),
"quoteOnlyImagePlaceholder": quote_only_image_placeholder,
"ocrTextExposed": ocr_text_exposed,
"quoteSource": quote_source,
})
}
fn citation_text_bundle(
raw_quote: Option<&str>,
search_query: Option<&str>,
locator_source_text: Option<&str>,
quote_source: &'static str,
) -> CitationTextBundle {
let raw_quote = raw_quote.unwrap_or_default().trim().to_string();
let search_query = search_query.unwrap_or_default().trim().to_string();
let locator_raw = locator_source_text
.filter(|value| !value.trim().is_empty())
.unwrap_or(&raw_quote);
let locator_window = if !search_query.is_empty() {
query_centered_quote(locator_raw, &search_query, 700)
.or_else(|| query_centered_quote(&raw_quote, &search_query, 700))
.unwrap_or_else(|| locator_raw.chars().take(700).collect::<String>())
} else {
locator_raw.chars().take(700).collect::<String>()
};
2026-06-09 18:40:48 +08:00
let display_source = locator_source_text
.filter(|value| !value.trim().is_empty())
.unwrap_or(&raw_quote);
let display_quote = clean_lightrag_text_for_display(display_source);
let locator_evidence_text = query_centered_clean_window(
&clean_lightrag_text_for_locator(&locator_window),
&search_query,
700,
);
CitationTextBundle {
raw_quote: raw_quote.clone(),
display_cleaned: display_quote != normalize_text_for_match(&raw_quote),
normalized_fingerprint: normalized_quote_fingerprint(&display_quote),
display_quote,
locator_evidence_text,
search_query,
locator_text_source: if locator_source_text.is_some() {
"sidecar_block"
} else {
quote_source
},
}
}
fn clean_lightrag_text_for_display(value: &str) -> String {
let without_tags = strip_lightrag_markup(value);
normalize_latex_plain_text(&without_tags)
}
fn clean_lightrag_text_for_locator(value: &str) -> String {
clean_lightrag_text_for_display(value)
}
fn strip_lightrag_markup(value: &str) -> String {
let mut output = String::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '<' {
output.push(ch);
continue;
}
let mut tag = String::new();
let mut closed = false;
for next in chars.by_ref() {
if next == '>' {
closed = true;
break;
}
tag.push(next);
if tag.chars().count() > 240 {
break;
}
}
output.push(' ');
if !closed {
break;
}
}
output
}
fn normalize_latex_plain_text(value: &str) -> String {
let mut output = String::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
let mut command = String::new();
while let Some(next) = chars.peek().copied() {
if next.is_ascii_alphabetic() {
command.push(next);
chars.next();
} else {
break;
}
}
match command.as_str() {
"gt" => output.push('>'),
"lt" => output.push('<'),
"sim" => output.push(''),
"circ" => output.push('∘'),
"alpha" => output.push('α'),
"beta" => output.push('β'),
"gamma" => output.push('γ'),
"delta" => output.push('δ'),
"Delta" => output.push('△'),
"left" | "right" | "mathrm" | "text" | "operatorname" => output.push(' '),
"" => output.push(' '),
_ => output.push(' '),
}
continue;
}
match ch {
'{' | '}' | '_' | '^' => output.push(' '),
_ => output.push(ch),
}
}
normalize_text_for_match(&output)
}
fn normalized_quote_fingerprint(value: &str) -> String {
normalize_text_for_match(value)
.chars()
.filter(|ch| ch.is_alphanumeric() || is_cjk_char(*ch))
.take(160)
.collect::<String>()
.to_ascii_lowercase()
}
fn query_centered_clean_window(value: &str, query: &str, max_chars: usize) -> String {
let text = normalize_text_for_match(value);
let query = normalize_text_for_match(query);
if text.is_empty() || query.is_empty() {
return text.chars().take(max_chars).collect();
}
let lower = text.to_ascii_lowercase();
let query_lower = query.to_ascii_lowercase();
let start = lower.find(&query_lower).unwrap_or(0);
text.get(start..)
.unwrap_or(&text)
.chars()
.take(max_chars)
.collect()
}
fn reference_display_quote(reference: &Value) -> &str {
reference
.get("displayQuote")
.and_then(Value::as_str)
.or_else(|| reference.get("quote").and_then(Value::as_str))
.unwrap_or_default()
}
fn reference_locator_evidence_text(reference: &Value) -> &str {
reference
.get("locatorEvidenceText")
.and_then(Value::as_str)
.or_else(|| reference.get("quote").and_then(Value::as_str))
.unwrap_or_default()
}
fn query_centered_quote(content: &str, query: &str, max_chars: usize) -> Option<String> {
let query_normalized = normalize_text_for_match(query).to_ascii_lowercase();
if query_normalized.is_empty() {
return None;
}
let lines = content.lines().collect::<Vec<_>>();
for (index, line) in lines.iter().enumerate() {
let line_normalized = normalize_text_for_match(line).to_ascii_lowercase();
if !line_normalized.contains(&query_normalized) {
continue;
}
let mut quote = String::new();
if index > 0 {
let previous = lines[index - 1].trim();
if previous.starts_with('#') {
quote.push_str(previous);
quote.push('\n');
}
}
for line in lines.iter().skip(index) {
let line = line.trim();
if line.is_empty() {
if !quote.is_empty() {
quote.push('\n');
}
continue;
}
let next_len = quote.chars().count() + line.chars().count() + 1;
if next_len > max_chars && !quote.trim().is_empty() {
break;
}
if !quote.is_empty() && !quote.ends_with('\n') {
quote.push('\n');
}
quote.push_str(line);
if quote.chars().count() >= max_chars {
break;
}
}
let quote = quote.trim();
if !quote.is_empty() {
return Some(quote.chars().take(max_chars).collect());
}
}
let content_lower = content.to_ascii_lowercase();
let byte_index = content_lower.find(&query_normalized)?;
Some(char_window_around_byte(content, byte_index, max_chars))
}
fn char_window_around_byte(content: &str, byte_index: usize, max_chars: usize) -> String {
let target_char_index = content[..byte_index].chars().count();
let before = max_chars / 3;
let start = target_char_index.saturating_sub(before);
content
.chars()
.skip(start)
.take(max_chars)
.collect::<String>()
.trim()
.to_string()
}
fn is_markdown_image_placeholder_line(line: &str) -> bool {
line.starts_with("![") && line.contains("](") && line.ends_with(')')
}
fn lightrag_locator_for_reference(
root_path: &Path,
root_uri: &str,
entry: &KnowledgeRagSourceRegistryEntry,
chunk_id: &Value,
query: Option<&str>,
locator_evidence_text: Option<&str>,
2026-06-09 18:40:48 +08:00
occurrence_index: &Value,
chunk_sidecar: Option<&Value>,
) -> Option<EvidenceLocator> {
2026-06-09 18:40:48 +08:00
let block = find_lightrag_sidecar_block(entry, query, occurrence_index, chunk_sidecar)?;
let positions = block
.get("positions")
.and_then(Value::as_array)
.map(|positions| parse_lightrag_locator_positions(positions.as_slice()))
.unwrap_or_default();
let resource_path = entry.source_root_relative_path.clone();
let block_content = block
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
let evidence_text = locator_evidence_text
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
query
.and_then(|query| query_centered_quote(block_content, query, 700))
.map(|value| clean_lightrag_text_for_locator(&value))
})
2026-06-09 18:40:48 +08:00
.unwrap_or_else(|| clean_lightrag_text_for_locator(block_content));
let mut open_params = json!({
"rootUri": root_uri,
"resourcePath": resource_path,
"provider": "lightrag",
"chunkId": chunk_id,
"searchQuery": query.unwrap_or_default(),
"query": evidence_text,
"evidenceText": evidence_text,
});
if let Some(map) = open_params.as_object_mut() {
if let Some(paragraph_ordinal) = positions.paragraph_ordinal {
map.insert("paragraphOrdinal".into(), json!(paragraph_ordinal));
}
if let Some(para_id_start) = positions.para_id_start.as_deref() {
map.insert("paraIdStart".into(), json!(para_id_start));
}
if let Some(para_id_end) = positions.para_id_end.as_deref() {
map.insert("paraIdEnd".into(), json!(para_id_end));
}
if let Some(text_fingerprint) = positions.text_fingerprint.as_deref() {
map.insert("textFingerprint".into(), json!(text_fingerprint));
}
}
let mut locator = EvidenceLocator::new(
root_uri,
fallback_owner_document_id(root_path, &resource_path).unwrap_or_default(),
&resource_path,
evidence_resource_kind_for_path(&resource_path),
EvidenceOpenAction {
action_type: "mnote.open_resource_locator".into(),
url: "/".into(),
params: open_params,
},
);
locator.resource_path = Some(entry.source_root_relative_path.clone());
if let Some(position) = positions.bbox {
locator.page = Some(position.page);
locator.bbox = Some(position.bbox);
}
locator.block_id = block
.get("blockid")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| chunk_id.as_str().map(ToOwned::to_owned));
locator.source_map_path =
sidecar_blocks_path(entry).and_then(|path| root_relative_path(root_path, &path).ok());
Some(locator)
}
fn evidence_resource_kind_for_path(path: &str) -> EvidenceResourceKind {
match Path::new(path)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.as_deref()
{
Some("pdf") => EvidenceResourceKind::Pdf,
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg") => {
EvidenceResourceKind::Image
}
Some("md" | "markdown" | "txt") => EvidenceResourceKind::Markdown,
Some("doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "csv") => {
EvidenceResourceKind::Office
}
_ => EvidenceResourceKind::RawFile,
}
}
#[derive(Debug, Clone)]
struct LightRagBboxPosition {
page: u32,
bbox: EvidenceBBox,
}
#[derive(Debug, Clone, Default)]
struct LightRagLocatorPositions {
bbox: Option<LightRagBboxPosition>,
paragraph_ordinal: Option<u32>,
para_id_start: Option<String>,
para_id_end: Option<String>,
text_fingerprint: Option<String>,
}
fn parse_lightrag_locator_positions(positions: &[Value]) -> LightRagLocatorPositions {
let mut parsed = LightRagLocatorPositions::default();
for position in positions {
match position.get("type").and_then(Value::as_str) {
Some("bbox") if parsed.bbox.is_none() => {
parsed.bbox = parse_bbox_position(position);
}
Some("paraid") => {
if parsed.paragraph_ordinal.is_none() {
parsed.paragraph_ordinal = position.get("anchor").and_then(value_to_u32);
}
if parsed.para_id_start.is_none() || parsed.para_id_end.is_none() {
if let Some(range) = position.get("range").and_then(Value::as_array) {
parsed.para_id_start = range
.first()
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
parsed.para_id_end = range
.get(1)
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| parsed.para_id_start.clone());
}
}
}
Some("text_fingerprint") if parsed.text_fingerprint.is_none() => {
parsed.text_fingerprint = position
.get("anchor")
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
_ => {}
}
}
parsed
}
fn parse_bbox_position(value: &Value) -> Option<LightRagBboxPosition> {
let page = value.get("anchor").and_then(value_to_u32)?;
let range = value.get("range")?.as_array()?;
if range.len() != 4 {
return None;
}
Some(LightRagBboxPosition {
page,
bbox: EvidenceBBox {
x0: range.first()?.as_f64()?,
y0: range.get(1)?.as_f64()?,
x1: range.get(2)?.as_f64()?,
y1: range.get(3)?.as_f64()?,
},
})
}
fn value_to_u32(value: &Value) -> Option<u32> {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.or_else(|| {
value
.as_str()
.and_then(|value| value.trim().parse::<u32>().ok())
})
}
fn find_lightrag_sidecar_block(
entry: &KnowledgeRagSourceRegistryEntry,
query: Option<&str>,
2026-06-09 18:40:48 +08:00
occurrence_index: &Value,
chunk_sidecar: Option<&Value>,
) -> Option<Value> {
2026-06-09 18:40:48 +08:00
let ref_ids = lightrag_sidecar_ref_ids(chunk_sidecar?);
if ref_ids.is_empty() {
return None;
}
2026-06-09 18:40:48 +08:00
let blocks = lightrag_sidecar_blocks_by_id(entry, &ref_ids)?;
let query = query
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(query) = query else {
return ref_ids.into_iter().find_map(|id| blocks.get(&id).cloned());
};
let target_occurrence = occurrence_index.as_u64().unwrap_or(0);
let mut seen_occurrences = 0u64;
for id in ref_ids {
let Some(block) = blocks.get(&id) else {
continue;
2026-06-09 18:40:48 +08:00
};
let count = block
.get("content")
.and_then(Value::as_str)
2026-06-09 18:40:48 +08:00
.map(|content| exact_match_count(content, &query))
.unwrap_or(0);
if count == 0 {
continue;
}
2026-06-09 18:40:48 +08:00
if seen_occurrences + count > target_occurrence {
return Some(block.clone());
}
seen_occurrences += count;
}
None
}
fn lightrag_sidecar_ref_ids(sidecar: &Value) -> Vec<String> {
let mut ids = Vec::new();
if let Some(id) = sidecar.get("id").and_then(Value::as_str) {
ids.push(id.trim().to_string());
}
if let Some(refs) = sidecar.get("refs").and_then(Value::as_array) {
for item in refs {
if let Some(id) = item.get("id").and_then(Value::as_str) {
ids.push(id.trim().to_string());
}
}
2026-06-09 18:40:48 +08:00
}
ids.retain(|id| !id.is_empty());
ids.dedup();
ids
}
fn lightrag_sidecar_blocks_by_id(
entry: &KnowledgeRagSourceRegistryEntry,
ref_ids: &[String],
) -> Option<BTreeMap<String, Value>> {
let wanted = ref_ids.iter().cloned().collect::<BTreeSet<_>>();
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut blocks = BTreeMap::new();
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
let Some(block_id) = block.get("blockid").and_then(Value::as_str) else {
continue;
};
if wanted.contains(block_id) && block.get("positions").and_then(Value::as_array).is_some() {
blocks.insert(block_id.to_string(), block);
}
if blocks.len() == wanted.len() {
break;
}
}
2026-06-09 18:40:48 +08:00
Some(blocks)
}
2026-06-09 18:40:48 +08:00
fn exact_match_count(content: &str, query: &str) -> u64 {
if query.is_empty() {
return 0;
}
let content_lower = content.to_lowercase();
let query_lower = query.to_lowercase();
let mut count = 0u64;
let mut start = 0usize;
while let Some(index) = content_lower[start..].find(&query_lower) {
count += 1;
start += index + query_lower.len().max(1);
}
count
}
2026-06-09 18:40:48 +08:00
fn lightrag_reference_sidecar(
reference: &Value,
primary_chunk: Option<&Value>,
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<Value> {
reference
.get("sidecar")
.cloned()
.or_else(|| {
primary_chunk
.and_then(|chunk| chunk.get("sidecar"))
.cloned()
})
.or_else(|| lightrag_chunk_sidecar_for_reference_ids(source_chunk_id, chunk_id))
}
fn block_matches_query_term(block_normalized: &str, term: &str) -> bool {
if block_normalized.contains(term) {
return true;
}
let term_chars = term.chars().collect::<Vec<_>>();
if term_chars.len() < 2 || !term_chars.iter().all(|ch| is_cjk_char(*ch)) {
return false;
}
let mut index = 0usize;
let mut gap = 0usize;
for ch in block_normalized.chars() {
if ch == term_chars[index] {
index += 1;
gap = 0;
if index == term_chars.len() {
return true;
}
continue;
}
if index > 0 {
gap += 1;
if gap > 4 {
index = 0;
gap = 0;
}
}
}
false
}
fn query_match_terms(query_normalized: &str) -> Vec<String> {
let mut terms = query_normalized
.split(|ch: char| ch.is_whitespace() || ch.is_ascii_punctuation())
.map(str::trim)
.filter(|term| !term.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
for term in terms.clone() {
let chars = term.chars().collect::<Vec<_>>();
if chars.len() < 4 || !chars.iter().any(|ch| is_cjk_char(*ch)) {
continue;
}
for window in chars.windows(2) {
terms.push(window.iter().collect::<String>());
}
for window in chars.windows(3) {
terms.push(window.iter().collect::<String>());
}
}
terms.sort();
terms.dedup();
terms
}
fn query_is_single_cjk_lookup(query_normalized: &str) -> bool {
let chars = query_normalized.chars().collect::<Vec<_>>();
!chars.is_empty()
&& chars.len() <= 8
&& chars
.iter()
.all(|ch| is_cjk_char(*ch) || ch.is_ascii_alphanumeric())
}
fn significant_cjk_query_term(term: &str) -> bool {
let chars = term.chars().collect::<Vec<_>>();
if chars.len() < 2 || !chars.iter().all(|ch| is_cjk_char(*ch)) {
return false;
}
!matches!(
term,
"请用"
| "使用"
| "资料"
| "资料库"
| "知识"
| "知识库"
| "检索"
| "搜索"
| "回答"
| "说明"
| "解释"
| "总结"
| "用途"
| "应用"
| "来源"
| "出处"
| "证据"
| "定位"
| "相关"
| "什么"
| "哪些"
| "怎么"
| "如何"
| "中的"
| "有什么"
)
}
fn is_cjk_char(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
|| ('\u{3400}'..='\u{4dbf}').contains(&ch)
|| ('\u{f900}'..='\u{faff}').contains(&ch)
}
fn normalize_text_for_match(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.collect::<String>()
}
fn sidecar_blocks_path(entry: &KnowledgeRagSourceRegistryEntry) -> Option<PathBuf> {
for file_path in lightrag_sidecar_file_path_candidates(entry) {
let stem = Path::new(&file_path).file_stem()?.to_string_lossy();
let path = lightrag_input_dir()
.join("__parsed__")
.join(format!("{file_path}.parsed"))
.join(format!("{stem}.blocks.jsonl"));
if path.exists() {
return Some(path);
}
}
None
}
fn lightrag_sidecar_file_path_candidates(entry: &KnowledgeRagSourceRegistryEntry) -> Vec<String> {
let mut candidates = vec![entry.light_rag_file_path.clone()];
if let Some(stripped) = strip_one_supported_parser_hint(&entry.light_rag_file_path) {
candidates.push(stripped);
}
if let Some(asset_path) = image_wrapper_asset_file_path(&entry.light_rag_file_path) {
candidates.push(asset_path);
}
candidates.sort();
candidates.dedup();
candidates
}
fn image_wrapper_asset_file_path(file_path: &str) -> Option<String> {
let asset_path = file_path.strip_suffix(".md")?;
if resource_type_for_path(asset_path) == "image" {
return Some(asset_path.to_string());
}
None
}
fn resource_type_for_path(path: &str) -> &'static str {
match Path::new(path)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.as_deref()
{
Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff") => "image",
Some("pdf") => "pdf",
Some("doc" | "docx") => "docx",
Some("ppt" | "pptx") => "pptx",
Some("xls" | "xlsx") => "xlsx",
_ => "resource",
}
}
fn knowledge_rag_source_content_diagnostics(registry: &KnowledgeRagSourceRegistry) -> Value {
Value::Array(
registry
.entries
.iter()
.map(|entry| {
let sidecar_path = sidecar_blocks_path(entry);
let sidecar_stats = sidecar_path
.as_ref()
.and_then(|path| sidecar_text_stats(path).ok());
json!({
"sourceId": entry.source_id,
"sourceRootRelativePath": entry.source_root_relative_path,
"lightRagDocId": entry.light_rag_doc_id,
"lightRagFilePath": entry.light_rag_file_path,
"lightRagStatus": entry.light_rag_status,
"directImageScan": resource_type_for_path(&entry.light_rag_file_path) == "image",
"sidecarExists": sidecar_path.is_some(),
"sidecarPath": sidecar_path.map(|path| path.display().to_string()),
"ocrTextExposed": sidecar_stats
.as_ref()
.map(|stats| stats.meaningful_blocks > 0)
.unwrap_or(false),
"sidecarBlocks": sidecar_stats.as_ref().map(|stats| stats.blocks).unwrap_or(0),
"sidecarMeaningfulBlocks": sidecar_stats
.as_ref()
.map(|stats| stats.meaningful_blocks)
.unwrap_or(0),
})
})
.collect(),
)
}
#[derive(Debug, Clone, Default)]
struct SidecarTextStats {
blocks: usize,
meaningful_blocks: usize,
}
fn sidecar_text_stats(path: &Path) -> Result<SidecarTextStats, WebError> {
let content = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_sidecar_read_failed",
format!("无法读取 LightRAG sidecar {}: {error}", path.display()),
)
})?;
let mut stats = SidecarTextStats::default();
for line in content.lines() {
let Ok(block) = serde_json::from_str::<Value>(line) else {
continue;
};
if block.get("type").and_then(Value::as_str) != Some("content") {
continue;
}
stats.blocks += 1;
let content = block
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
if quote_content_diagnostics(Some(content), "sidecar")
.get("ocrTextExposed")
.and_then(Value::as_bool)
.unwrap_or(false)
{
stats.meaningful_blocks += 1;
}
}
Ok(stats)
}
2026-06-09 18:40:48 +08:00
fn lightrag_chunk_content_for_reference_ids(
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<String> {
lightrag_chunk_id_candidates(source_chunk_id, chunk_id)
.into_iter()
.find_map(|id| lightrag_chunk_content_for_id(&id))
}
fn lightrag_chunk_sidecar_for_reference_ids(
source_chunk_id: &Value,
chunk_id: &Value,
) -> Option<Value> {
lightrag_chunk_id_candidates(source_chunk_id, chunk_id)
.into_iter()
.find_map(|id| {
lightrag_chunk_value_for_id(&id).and_then(|chunk| chunk.get("sidecar").cloned())
})
}
fn lightrag_chunk_id_candidates(source_chunk_id: &Value, chunk_id: &Value) -> Vec<String> {
let mut ids = Vec::new();
for value in [source_chunk_id, chunk_id] {
let Some(raw) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
ids.push(raw.to_string());
if let Some((base, _)) = raw.split_once("#match-") {
ids.push(base.to_string());
}
}
ids.sort();
ids.dedup();
ids
}
fn lightrag_chunk_value_for_id(chunk_id: &str) -> Option<Value> {
let content =
fs::read_to_string(lightrag_working_dir().join("kv_store_text_chunks.json")).ok()?;
let chunks = serde_json::from_str::<Value>(&content).ok()?;
2026-06-09 18:40:48 +08:00
chunks.get(chunk_id).cloned()
}
fn lightrag_chunk_content_for_id(chunk_id: &str) -> Option<String> {
lightrag_chunk_value_for_id(chunk_id)
.as_ref()
.and_then(|chunk| chunk.get("content"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn read_registry(
root_path: &Path,
workspace_id: &str,
root_uri: &str,
) -> Result<KnowledgeRagSourceRegistry, WebError> {
let path = registry_path(root_path);
if !path.exists() {
return Ok(KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: workspace_id.to_string(),
root_uri: root_uri.to_string(),
updated_at_ms: now_ms(),
indexed_roots: Vec::new(),
entries: Vec::new(),
});
}
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_registry_read_failed",
format!("无法读取 LightRAG source registry: {error}"),
)
})?;
serde_json::from_str(&content).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_registry_invalid",
format!("LightRAG source registry JSON 无效: {error}"),
)
})
}
fn write_registry(
root_path: &Path,
registry: &mut KnowledgeRagSourceRegistry,
) -> Result<(), WebError> {
registry.updated_at_ms = now_ms();
let path = registry_path(root_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_registry_dir_failed",
format!("无法创建 LightRAG source registry 目录: {error}"),
)
})?;
}
let content = serde_json::to_string_pretty(registry).map_err(|error| {
WebError::internal(format!("无法序列化 LightRAG source registry: {error}"))
})?;
fs::write(&path, format!("{content}\n")).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_registry_write_failed",
format!("无法写入 LightRAG source registry: {error}"),
)
})
}
fn upsert_registry_entry(
registry: &mut KnowledgeRagSourceRegistry,
next: KnowledgeRagSourceRegistryEntry,
) {
if let Some(existing) = registry
.entries
.iter_mut()
.find(|entry| entry.source_path == next.source_path)
{
*existing = next;
return;
}
registry.entries.push(next);
}
fn upsert_indexed_root_for_request(
registry: &mut KnowledgeRagSourceRegistry,
root_path: &Path,
source_path: &str,
context: &RequestContext,
) -> Result<(), WebError> {
let canonical = ensure_source_path_in_root(root_path, source_path, context)?;
let relative = root_relative_path(root_path, &canonical)?;
let normalized = relative.trim_matches('/').replace('\\', "/");
let recursive = canonical.is_dir();
let now = now_ms();
if let Some(existing) = registry
.indexed_roots
.iter_mut()
.find(|root| root.root_relative_path == normalized)
{
existing.recursive = recursive;
existing.run_on_change.get_or_insert(true);
existing.updated_at_ms = now;
return Ok(());
}
registry.indexed_roots.push(KnowledgeRagIndexedRoot {
root_relative_path: normalized,
recursive,
exclude_patterns: Vec::new(),
run_on_change: Some(true),
updated_at_ms: now,
});
registry
.indexed_roots
.sort_by(|left, right| left.root_relative_path.cmp(&right.root_relative_path));
Ok(())
}
fn registry_path(root_path: &Path) -> PathBuf {
root_path
.join(".mnote")
.join("index")
.join("lightrag-source-registry.json")
}
#[derive(Debug, Clone)]
struct ResolvedKnowledgeRagSource {
canonical_path: PathBuf,
requested_path: String,
source_kind: &'static str,
}
fn resolve_knowledge_rag_sources(
root_path: &Path,
source_path: &str,
context: &RequestContext,
) -> Result<Vec<ResolvedKnowledgeRagSource>, WebError> {
let canonical = ensure_source_path_in_root(root_path, source_path, context)?;
if canonical.is_file() {
if !knowledge_rag_source_supported_file(&canonical) {
return Err(WebError::bad_request_code(
"knowledge_rag_source_unsupported",
"资料库 source 文件类型暂不支持",
)
.with_context(context));
}
return Ok(vec![ResolvedKnowledgeRagSource {
canonical_path: canonical,
requested_path: source_path.trim().to_string(),
source_kind: "file",
}]);
}
if canonical.is_dir() {
let mut files = collect_knowledge_rag_directory_sources(root_path, &canonical, context)?;
if files.is_empty() {
return Err(WebError::bad_request_code(
"knowledge_rag_source_directory_empty",
"资料库目录中没有可索引文件",
)
.with_context(context));
}
files.sort();
files.dedup();
return Ok(files
.into_iter()
.map(|path| ResolvedKnowledgeRagSource {
canonical_path: path,
requested_path: source_path.trim().to_string(),
source_kind: "directory",
})
.collect());
}
Err(WebError::bad_request_code(
"knowledge_rag_source_not_file_or_directory",
"资料库 source 必须是文件或目录",
)
.with_context(context))
}
fn collect_knowledge_rag_directory_sources(
root_path: &Path,
directory: &Path,
context: &RequestContext,
) -> Result<Vec<PathBuf>, WebError> {
let mut files = Vec::new();
let mut stack = vec![directory.to_path_buf()];
while let Some(current_dir) = stack.pop() {
let mut entries = fs::read_dir(&current_dir)
.map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_source_directory_read_failed",
format!("无法读取资料库目录 {}: {error}", current_dir.display()),
)
.with_context(context)
})?
.filter_map(Result::ok)
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.path());
for entry in entries {
let path = entry.path();
let canonical = path.canonicalize().map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_source_unavailable",
format!("无法访问资料库 source: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(root_path) {
continue;
}
if canonical.is_dir() {
if should_skip_knowledge_rag_directory(&canonical) {
continue;
}
stack.push(canonical);
continue;
}
if canonical.is_file() && knowledge_rag_source_supported_file(&canonical) {
files.push(canonical);
if files.len() > MAX_INGEST_SOURCES_PER_REQUEST {
return Err(WebError::bad_request_code(
"knowledge_rag_source_limit_exceeded",
format!(
"单次资料库索引最多支持 {MAX_INGEST_SOURCES_PER_REQUEST} 个文件,请缩小目录范围"
),
)
.with_context(context));
}
}
}
}
Ok(files)
}
fn should_skip_knowledge_rag_directory(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
matches!(
name,
".git" | ".mnote" | "node_modules" | "target" | "__parsed__" | ".venv"
)
}
fn knowledge_rag_source_supported_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.map(|value| {
KNOWLEDGE_RAG_SOURCE_EXTENSIONS
.iter()
.any(|extension| value.eq_ignore_ascii_case(extension))
})
.unwrap_or(false)
}
fn lightrag_scan_supported_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.map(|value| {
LIGHTRAG_SCAN_SOURCE_EXTENSIONS
.iter()
.any(|extension| value.eq_ignore_ascii_case(extension))
})
.unwrap_or(false)
}
struct StagedLightRagSource {
light_rag_file_path: String,
staged_path: PathBuf,
}
fn stage_lightrag_source(
canonical: &Path,
file_name: &str,
parser_hint: Option<&str>,
input_dir: &Path,
context: &RequestContext,
) -> Result<StagedLightRagSource, WebError> {
if lightrag_scan_supported_file(canonical) {
let light_rag_file_path = lightrag_symlink_name(canonical, file_name, parser_hint);
let symlink_path = input_dir.join(&light_rag_file_path);
replace_lightrag_input_file(&symlink_path, context)?;
create_symlink(canonical, &symlink_path, context)?;
return Ok(StagedLightRagSource {
light_rag_file_path,
staged_path: symlink_path,
});
}
let asset_file_path = lightrag_symlink_name(canonical, file_name, None);
let asset_symlink_path = input_dir.join(&asset_file_path);
replace_lightrag_input_file(&asset_symlink_path, context)?;
create_symlink(canonical, &asset_symlink_path, context)?;
let wrapper_file_path = format!("{asset_file_path}.md");
let wrapper_path = input_dir.join(&wrapper_file_path);
replace_lightrag_input_file(&wrapper_path, context)?;
let wrapper_markdown = format!("# {file_name}\n\n![{file_name}](<{asset_file_path}>)\n");
fs::write(&wrapper_path, wrapper_markdown).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_wrapper_write_failed",
format!("无法写入 LightRAG 图片包装 Markdown: {error}"),
)
.with_context(context)
})?;
Ok(StagedLightRagSource {
light_rag_file_path: wrapper_file_path,
staged_path: wrapper_path,
})
}
fn replace_lightrag_input_file(path: &Path, context: &RequestContext) -> Result<(), WebError> {
if path.exists() || path.symlink_metadata().is_ok() {
fs::remove_file(path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_symlink_replace_failed",
format!("无法替换 LightRAG source 文件: {error}"),
)
.with_context(context)
})?;
}
Ok(())
}
fn ensure_source_path_in_root(
root_path: &Path,
source_path: &str,
context: &RequestContext,
) -> Result<PathBuf, WebError> {
let raw_path = source_path
.trim()
.strip_prefix("file://")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(source_path.trim()));
let target = if raw_path.is_absolute() {
raw_path
} else {
root_path.join(raw_path)
};
let canonical = target.canonicalize().map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_source_unavailable",
format!("无法访问资料库 source: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(root_path) {
return Err(WebError::bad_request_code(
"knowledge_rag_source_root_escape",
"资料库 source 不能越过授权目录",
)
.with_context(context));
}
Ok(canonical)
}
fn root_relative_path(root_path: &Path, source_path: &Path) -> Result<String, WebError> {
let relative = source_path.strip_prefix(root_path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_source_relative_failed",
format!("无法生成资料库 source 相对路径: {error}"),
)
})?;
Ok(relative.to_string_lossy().replace('\\', "/"))
}
fn create_symlink(
source: &Path,
symlink_path: &Path,
context: &RequestContext,
) -> Result<(), WebError> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(source, symlink_path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_symlink_failed",
format!("无法创建 LightRAG source symlink: {error}"),
)
.with_context(context)
})
}
#[cfg(not(unix))]
{
fs::copy(source, symlink_path).map(|_| ()).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_staging_copy_failed",
format!("无法创建 LightRAG source staging 文件: {error}"),
)
.with_context(context)
})
}
}
fn source_hash(path: &Path) -> Result<String, WebError> {
let bytes = fs::read(path).map_err(|error| {
WebError::bad_request_code(
"knowledge_rag_source_hash_failed",
format!("无法读取资料库 source 以计算 hash: {error}"),
)
})?;
Ok(format!("mnote-fnv64:{:016x}", fnv64(&bytes)))
}
fn fnv64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
fn lightrag_symlink_name(path: &Path, file_name: &str, parser_hint: Option<&str>) -> String {
let prefix = format!("mnote-{}-", short_hash(&path.display().to_string()));
let Some(parser_hint) = parser_hint else {
return format!("{prefix}{file_name}");
};
if strip_one_supported_parser_hint(file_name).is_some() {
return format!("{prefix}{file_name}");
}
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(file_name);
let extension = path.extension().and_then(|value| value.to_str());
match extension {
Some(extension) if !extension.is_empty() => {
format!("{prefix}{stem}.[{parser_hint}].{extension}")
}
_ => format!("{prefix}{stem}.[{parser_hint}]"),
}
}
fn normalize_parser_hint(
value: Option<&str>,
context: &RequestContext,
) -> Result<Option<String>, WebError> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let hint = value.trim_matches('[').trim_matches(']').trim();
if let Some(normalized) = normalize_supported_parser_hint(hint) {
return Ok(Some(normalized));
}
Err(WebError::bad_request_code(
"knowledge_rag_parser_hint_invalid",
"LightRAG parserHint 仅支持 legacy/native/mineru/docling 及 native-P 这类 engine-options",
)
.with_context(context))
}
fn default_lightrag_parser_hint_for_source(path: &Path, file_name: &str) -> Option<String> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
if extension.as_deref() != Some("docx") {
return None;
}
let lower_name = file_name.to_ascii_lowercase();
if lower_name.contains("[ocr]") && lower_name.contains(".layered") {
return Some("native-P".into());
}
None
}
fn normalize_supported_parser_hint(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Some(options) = trimmed.strip_prefix('-') {
return valid_lightrag_parser_options(options).then(|| format!("-{options}"));
}
let (engine, options) = trimmed
.split_once('-')
.map(|(engine, options)| (engine, Some(options)))
.unwrap_or((trimmed, None));
let engine = engine.to_ascii_lowercase();
if !matches!(engine.as_str(), "legacy" | "native" | "mineru" | "docling") {
return None;
}
match options {
Some(options) if valid_lightrag_parser_options(options) => {
Some(format!("{engine}-{options}"))
}
Some(_) => None,
None => Some(engine),
}
}
fn valid_lightrag_parser_options(options: &str) -> bool {
!options.is_empty()
&& options
.chars()
.all(|value| matches!(value, 'i' | 't' | 'e' | 'R' | 'F' | 'P'))
}
fn document_for_registry_entry<'a>(
by_file_path: &'a BTreeMap<String, Value>,
entry: &KnowledgeRagSourceRegistryEntry,
) -> Option<&'a Value> {
by_file_path.get(&entry.light_rag_file_path).or_else(|| {
strip_one_supported_parser_hint(&entry.light_rag_file_path)
.as_ref()
.and_then(|path| by_file_path.get(path))
})
}
fn lightrag_file_path_matches(entry: &KnowledgeRagSourceRegistryEntry, file_path: &str) -> bool {
entry.light_rag_file_path == file_path
|| strip_one_supported_parser_hint(&entry.light_rag_file_path).as_deref() == Some(file_path)
}
fn strip_one_supported_parser_hint(file_name: &str) -> Option<String> {
let mut search_start = 0usize;
while let Some(offset) = file_name.get(search_start..)?.find(".[") {
let start = search_start + offset;
let hint_start = start + 2;
let Some(end_offset) = file_name.get(hint_start..)?.find(']') else {
break;
};
let hint_end = hint_start + end_offset;
let hint = file_name.get(hint_start..hint_end)?;
if normalize_supported_parser_hint(hint).is_some() {
let mut stripped = String::new();
stripped.push_str(file_name.get(..start)?);
stripped.push_str(file_name.get(hint_end + 1..)?);
return Some(stripped);
}
search_start = hint_end + 1;
}
None
}
fn short_hash(value: &str) -> String {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn short_citation_id(
source_id: &str,
chunk_id: &str,
block_id: &str,
occurrence_index: u64,
fingerprint: &str,
) -> String {
short_hash(&format!(
"lightrag\n{source_id}\n{chunk_id}\n{block_id}\n{occurrence_index}\n{fingerprint}"
))
.chars()
.take(4)
.collect()
}
fn lightrag_endpoint() -> String {
env::var("MNOTE_LIGHTRAG_ENDPOINT")
.or_else(|_| env::var("LIGHTRAG_ENDPOINT"))
.unwrap_or_else(|_| DEFAULT_LIGHTRAG_ENDPOINT.into())
.trim()
.trim_end_matches('/')
.to_string()
}
fn lightrag_dashboard_url() -> String {
env::var("MNOTE_LIGHTRAG_DASHBOARD_URL").unwrap_or_else(|_| lightrag_endpoint())
}
fn lightrag_input_dir() -> PathBuf {
if let Ok(value) = env::var("MNOTE_LIGHTRAG_INPUT_DIR") {
return PathBuf::from(value);
}
if let Some(value) = read_lightrag_dotenv_value("INPUT_DIR") {
return PathBuf::from(value);
}
env::var("INPUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LIGHTRAG_INPUT_DIR))
}
fn lightrag_working_dir() -> PathBuf {
if let Ok(value) = env::var("MNOTE_LIGHTRAG_WORKING_DIR") {
return PathBuf::from(value);
}
if let Some(value) = read_lightrag_dotenv_value("WORKING_DIR") {
return PathBuf::from(value);
}
env::var("WORKING_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LIGHTRAG_WORKING_DIR))
}
fn lightrag_api_key() -> Option<String> {
env::var("MNOTE_LIGHTRAG_API_KEY")
.ok()
.map(|value| value.trim().trim_matches('"').to_string())
.filter(|value| !value.is_empty())
.or_else(|| read_lightrag_dotenv_value("LIGHTRAG_API_KEY"))
.or_else(|| env::var("LIGHTRAG_API_KEY").ok())
.map(|value| value.trim().trim_matches('"').to_string())
.filter(|value| !value.is_empty())
}
fn read_lightrag_dotenv_value(key: &str) -> Option<String> {
let path = env::var("MNOTE_LIGHTRAG_ENV_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"));
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((name, value)) = line.split_once('=') else {
continue;
};
if name.trim() != key {
continue;
}
let value = value.trim().trim_matches('"').to_string();
if !value.is_empty() {
return Some(value);
}
}
None
}
fn effective_workspace_id(input: Option<&str>, root_uri: &str) -> String {
input
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
local_folder_source::local_workspace_id_from_root_uri(root_uri)
.unwrap_or_else(|_| format!("local-ws:{}", short_hash(root_uri)))
})
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, Method, Uri};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn test_context() -> RequestContext {
RequestContext::from_http_parts(&Method::GET, &Uri::from_static("/"), &HeaderMap::new())
}
fn temp_root(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{label}-{}", now_ms()));
fs::create_dir_all(&root).expect("temp root");
root
}
fn test_registry_entry(
root: &Path,
relative: &str,
doc_id: Option<&str>,
provider_status: Option<&str>,
indexed_at_ms: Option<u128>,
deleted_at_ms: Option<u128>,
stale: bool,
) -> KnowledgeRagSourceRegistryEntry {
KnowledgeRagSourceRegistryEntry {
source_id: format!("src-{relative}"),
workspace_id: "ws".into(),
root_uri: format!("file://{}", root.display()),
source_path: root.join(relative).display().to_string(),
source_root_relative_path: relative.into(),
source_hash: "mnote-fnv64:test".into(),
light_rag_doc_id: doc_id.map(ToOwned::to_owned),
light_rag_status: provider_status.map(ToOwned::to_owned),
light_rag_file_path: relative.into(),
symlink_path: format!("/tmp/{relative}"),
parser_hint: None,
indexed_at_ms,
deleted_at_ms,
stale,
updated_at_ms: 2,
}
}
#[tokio::test]
async fn retired_local_ocr_endpoint_points_to_lightrag_replacements() {
let (status, Json(payload)) = retired_local_ocr_endpoint(Extension(test_context())).await;
assert_eq!(status, StatusCode::GONE);
assert_eq!(payload["code"], "mnote_local_ocr_retired");
assert_eq!(payload["replacement"]["provider"], "lightrag");
assert_eq!(
payload["replacement"]["ingest"],
"/api/knowledge-rag/ingest"
);
}
#[test]
fn lightrag_document_summaries_expose_status_groups_for_ui_sync() {
let docs = json!({
"statuses": {
"processed": [
{"id": "doc-ok", "file_path": "ok.pdf", "status": "processed", "summary": "ok"}
],
"failed": [
{"id": "doc-fail", "file_path": "fail.pdf", "status": "failed", "summary": "bad"}
]
}
});
let counts = lightrag_document_status_group_counts(&docs);
assert_eq!(counts.get("processed"), Some(&1));
assert_eq!(counts.get("failed"), Some(&1));
let summaries = lightrag_document_summaries(&docs);
assert_eq!(summaries.len(), 2);
assert!(summaries.iter().any(|item| item["filePath"] == "ok.pdf"));
assert!(summaries.iter().any(|item| item["statusGroup"] == "failed"));
let by_path = lightrag_documents_by_file_path(&docs);
assert!(by_path.contains_key("ok.pdf"));
assert!(by_path.contains_key("fail.pdf"));
}
#[test]
fn lightrag_pipeline_summary_extracts_chunk_progress_for_ui() {
let status = json!({
"busy": true,
"scanning": true,
"job_name": "book.docx",
"latest_message": "Chunk 212 of 333 extracted 4 Ent + 0 Rel doc-ff0b60997a285a85e5704a114d7b3ffa-chunk-212"
});
let summary = lightrag_pipeline_status_summary(&status);
assert_eq!(summary["busy"], true);
assert_eq!(summary["scanning"], true);
assert_eq!(summary["progress"]["current"], 212);
assert_eq!(summary["progress"]["total"], 333);
assert_eq!(
summary["progress"]["docId"],
"doc-ff0b60997a285a85e5704a114d7b3ffa"
);
}
#[test]
fn old_registry_json_defaults_indexed_roots() {
let registry: KnowledgeRagSourceRegistry = serde_json::from_value(json!({
"schema": REGISTRY_SCHEMA,
"workspaceId": "ws",
"rootUri": "file:///tmp/root",
"updatedAtMs": 1,
"entries": []
}))
.expect("registry");
assert!(registry.indexed_roots.is_empty());
}
#[test]
fn ingest_request_records_explicit_indexed_root_scope() {
let root = temp_root("mnote-knowledge-rag-indexed-root");
fs::create_dir_all(root.join("papers")).expect("papers");
fs::write(root.join("papers").join("a.pdf"), b"pdf").expect("pdf");
let mut registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: format!("file://{}", root.display()),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: Vec::new(),
};
upsert_indexed_root_for_request(&mut registry, &root, "papers", &test_context())
.expect("indexed root");
assert_eq!(registry.indexed_roots.len(), 1);
assert_eq!(registry.indexed_roots[0].root_relative_path, "papers");
assert!(registry.indexed_roots[0].recursive);
assert_eq!(registry.indexed_roots[0].run_on_change, Some(true));
let _ = fs::remove_dir_all(root);
}
#[test]
fn references_only_search_result_is_clickable_source_hit() {
let reference = json!({
"provider": "lightrag",
"sourceId": "src-paper",
"sourceRootRelativePath": "papers/a.pdf",
"chunkId": "chunk-1",
"quote": "Canterbury corpus compression ratio appears in this paragraph.",
"locatorDegraded": false,
"locator": {
"rootUri": "file:///tmp/root",
"ownerDocumentId": "local-md:papers~2FHost.md",
"resourcePath": "papers/a.pdf",
"resourceKind": "pdf",
"page": 3
},
"citationUrl": "/documents/local-md:papers~2FHost.md?resourcePath=papers%2Fa.pdf",
"citationMarkdown": "[a.pdf · p.3](/documents/local-md:papers~2FHost.md)"
});
let result = knowledge_rag_search_result(&reference, 0, "file:///tmp/root", "三甲基硅基");
assert_eq!(result["provider"], "lightrag");
assert_eq!(result["query"], "三甲基硅基");
assert_eq!(result["matchSource"], "lightrag_reference");
assert_eq!(result["resourceType"], "pdf");
assert_eq!(result["path"], "papers/a.pdf");
assert_eq!(result["locator"]["page"], 3);
assert_eq!(result["citationUrl"], reference["citationUrl"]);
}
#[test]
fn reference_mapping_uses_registry_source_path() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/books/a.pdf".into(),
source_root_relative_path: "books/a.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-a.pdf".into(),
symlink_path: "/tmp/input/mnote-hash-a.pdf".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({"file_path":"mnote-hash-a.pdf","chunk_id":"chunk1"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
assert_eq!(mapped["sourceRootRelativePath"], "books/a.pdf");
assert_eq!(mapped["locatorDegraded"], true);
assert_eq!(mapped["openAction"]["params"]["path"], "books/a.pdf");
}
#[test]
fn missing_registry_reference_is_degraded() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![],
};
let mapped = map_reference_plan(
&json!({"file_path":"unknown.pdf"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
assert_eq!(mapped["locatorDegraded"], true);
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
}
#[test]
fn reference_mapping_does_not_match_deleted_entry_when_doc_id_missing() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "deleted".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/deleted.md".into(),
source_root_relative_path: "deleted.md".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: None,
light_rag_status: None,
light_rag_file_path: "deleted.md".into(),
symlink_path: "/tmp/input/deleted.md".into(),
parser_hint: None,
indexed_at_ms: None,
deleted_at_ms: Some(2),
stale: true,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({"file_path":"active.md","chunk_id":"chunk-active"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
assert!(mapped["sourceRootRelativePath"].is_null());
assert_eq!(mapped["stale"], false);
assert_eq!(mapped["deleted"], false);
}
#[test]
fn degraded_known_resource_keeps_clickable_resource_tab_url() {
let root = temp_root("mnote-knowledge-rag-docx-fallback");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("Host.md"), "# Host\n").expect("host");
fs::write(root.join("docs").join("a.docx"), b"docx").expect("docx");
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("a.docx").display().to_string(),
source_root_relative_path: "docs/a.docx".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-a.docx".into(),
symlink_path: "/tmp/input/mnote-hash-a.docx".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({"file_path":"mnote-hash-a.docx","chunk_id":"chunk1"}),
&registry,
"file:///tmp/root",
&root,
None,
);
assert_eq!(mapped["locatorDegraded"], true);
let citation_url = mapped["citationUrl"].as_str().expect("citation url");
assert!(citation_url.starts_with("/documents/local-md:docs~2FHost.md?"));
assert!(citation_url.contains("resourceTab="));
assert!(citation_url.contains("resourcePath=docs%2Fa.docx"));
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_state_marks_deleted_and_changed_entries_stale() {
let root = temp_root("mnote-knowledge-rag-source-state");
fs::create_dir_all(root.join("docs")).expect("docs");
let changed_path = root.join("docs").join("changed.md");
fs::write(&changed_path, "new content").expect("changed");
let missing_path = root.join("docs").join("missing.md");
let mut registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![
KnowledgeRagSourceRegistryEntry {
source_id: "missing".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: missing_path.display().to_string(),
source_root_relative_path: "docs/missing.md".into(),
source_hash: "mnote-fnv64:old".into(),
light_rag_doc_id: Some("doc-missing".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "missing.md".into(),
symlink_path: "/tmp/input/missing.md".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
},
KnowledgeRagSourceRegistryEntry {
source_id: "changed".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: changed_path.display().to_string(),
source_root_relative_path: "docs/changed.md".into(),
source_hash: "mnote-fnv64:old".into(),
light_rag_doc_id: Some("doc-changed".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "changed.md".into(),
symlink_path: "/tmp/input/changed.md".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
},
],
};
let stale_doc_ids = sync_registry_source_state(&mut registry, 42).expect("sync");
assert_eq!(stale_doc_ids, vec!["doc-changed", "doc-missing"]);
assert_eq!(registry.entries[0].deleted_at_ms, Some(42));
2026-06-07 10:35:21 +08:00
assert_eq!(
registry.entries[0].light_rag_doc_id.as_deref(),
Some("doc-missing")
);
assert_eq!(
registry.entries[0].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[0].indexed_at_ms, None);
assert!(registry.entries[0].stale);
assert_eq!(registry.entries[1].deleted_at_ms, None);
2026-06-07 10:35:21 +08:00
assert_eq!(
registry.entries[1].light_rag_doc_id.as_deref(),
Some("doc-changed")
);
assert_eq!(
registry.entries[1].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[1].indexed_at_ms, None);
assert!(registry.entries[1].stale);
assert_ne!(registry.entries[1].source_hash, "mnote-fnv64:old");
let _ = fs::remove_dir_all(root);
}
2026-06-07 10:35:21 +08:00
#[test]
fn provider_delete_confirmation_clears_doc_id_for_deleted_or_stale_entries() {
let root = temp_root("mnote-knowledge-rag-delete-confirmed");
let mut deleted = test_registry_entry(
&root,
"deleted.pdf",
Some("doc-deleted"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
);
let mut changed = test_registry_entry(
&root,
"changed.pdf",
Some("doc-changed"),
Some("delete_submitted"),
Some(2),
None,
true,
);
let active = test_registry_entry(
&root,
"active.pdf",
Some("doc-active"),
Some("processed"),
Some(2),
None,
false,
);
assert!(knowledge_rag_provider_delete_confirmed(&deleted));
mark_registry_entry_delete_completed(&mut deleted, 42);
assert_eq!(deleted.light_rag_doc_id, None);
assert_eq!(deleted.indexed_at_ms, None);
assert_eq!(
deleted.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(knowledge_rag_provider_delete_confirmed(&changed));
mark_registry_entry_delete_completed(&mut changed, 43);
assert_eq!(changed.light_rag_doc_id, None);
assert_eq!(
changed.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(!knowledge_rag_provider_delete_confirmed(&active));
assert_eq!(active.light_rag_doc_id.as_deref(), Some("doc-active"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_statuses_distinguish_indexed_processing_failed_and_deleted() {
let root = temp_root("mnote-knowledge-rag-source-statuses");
fs::write(root.join("indexed.pdf"), b"indexed").expect("indexed");
fs::write(root.join("submitted.pdf"), b"submitted").expect("submitted");
fs::write(root.join("failed.pdf"), b"failed").expect("failed");
fs::write(root.join("deleting.pdf"), b"deleting").expect("deleting");
fs::write(root.join("removed.pdf"), b"removed").expect("removed");
let root_uri = format!("file://{}", root.display());
let mut registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: root_uri.clone(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![
test_registry_entry(
&root,
"indexed.pdf",
Some("doc-indexed"),
Some("processed"),
Some(2),
None,
false,
),
test_registry_entry(
&root,
"submitted.pdf",
None,
Some("submitted"),
None,
None,
false,
),
test_registry_entry(&root, "failed.pdf", None, Some("failed"), None, None, false),
test_registry_entry(
&root,
"deleting.pdf",
Some("doc-deleting"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
),
test_registry_entry(
&root,
"removed.pdf",
None,
Some("delete_completed"),
None,
Some(3),
true,
),
],
};
write_registry(&root, &mut registry).expect("write registry");
let statuses = knowledge_rag_source_statuses(&root, "ws", &root_uri).expect("statuses");
assert!(statuses.indexed_paths.contains("indexed.pdf"));
assert!(statuses.indexing_paths.contains("submitted.pdf"));
assert!(statuses.indexing_paths.contains("deleting.pdf"));
assert!(statuses.failed_paths.contains("failed.pdf"));
assert!(!statuses.indexed_paths.contains("removed.pdf"));
assert!(!statuses.indexing_paths.contains("removed.pdf"));
assert!(!statuses.failed_paths.contains("removed.pdf"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn image_source_is_staged_directly_for_lightrag_scan() {
let root = temp_root("mnote-knowledge-rag-image-direct-scan");
let input_dir = root.join("inputs");
fs::create_dir_all(&input_dir).expect("input dir");
let image_path = root.join("image copy 6.png");
fs::write(&image_path, b"png").expect("image");
let staged = stage_lightrag_source(
&image_path,
"image copy 6.png",
None,
&input_dir,
&test_context(),
)
.expect("stage image");
assert!(staged.light_rag_file_path.ends_with(".png"));
assert!(staged.staged_path.symlink_metadata().is_ok());
#[cfg(unix)]
assert!(staged
.staged_path
.symlink_metadata()
.expect("staged metadata")
.file_type()
.is_symlink());
let _ = fs::remove_dir_all(root);
}
#[test]
fn registry_prune_predicate_keeps_inflight_delete_until_confirmed() {
let root = temp_root("mnote-knowledge-rag-prune-predicate");
let active = test_registry_entry(
&root,
"active.pdf",
Some("doc-active"),
Some("processed"),
Some(2),
None,
false,
);
let deleting = test_registry_entry(
&root,
"deleting.pdf",
Some("doc-deleting"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
);
let removed = test_registry_entry(
&root,
"removed.pdf",
None,
Some("delete_completed"),
None,
Some(3),
true,
);
let failed =
test_registry_entry(&root, "failed.pdf", None, Some("failed"), None, None, false);
2026-06-07 10:35:21 +08:00
let retry = test_registry_entry(
&root,
"retry.pdf",
Some("doc-retry"),
Some("delete_retry_required"),
Some(2),
Some(3),
true,
);
let stale_with_doc = test_registry_entry(
&root,
"stale.pdf",
Some("doc-stale"),
Some("processed"),
Some(2),
None,
true,
);
assert!(!knowledge_rag_registry_entry_prunable(&active));
assert!(!knowledge_rag_registry_entry_prunable(&deleting));
2026-06-07 10:35:21 +08:00
assert!(!knowledge_rag_registry_entry_prunable(&retry));
assert!(!knowledge_rag_registry_entry_prunable(&stale_with_doc));
assert!(knowledge_rag_registry_entry_prunable(&removed));
assert!(knowledge_rag_registry_entry_prunable(&failed));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_scope_filters_mapped_references_by_file_or_directory() {
let mut references = vec![
json!({"sourceRootRelativePath": "books/a.pdf"}),
json!({"sourceRootRelativePath": "papers/one/b.pdf"}),
json!({"sourceRootRelativePath": "papers/two/c.pdf"}),
];
let scope = normalize_source_scope(Some(&[
"books/a.pdf".to_string(),
"papers/one".to_string(),
"papers/one".to_string(),
]));
assert_eq!(scope, vec!["books/a.pdf", "papers/one"]);
filter_mapped_references_by_source_scope(&mut references, &scope);
let paths = references
.iter()
.filter_map(|reference| reference["sourceRootRelativePath"].as_str())
.collect::<Vec<_>>();
assert_eq!(paths, vec!["books/a.pdf", "papers/one/b.pdf"]);
}
#[test]
fn search_result_reports_paragraph_locator_precision_without_bbox() {
let reference = json!({
"sourceRootRelativePath": "docs/book.docx",
"sourceId": "src1",
"chunkId": "chunk-1",
"quote": "raw should not be used",
"displayQuote": "吡咯烷,5 h,90%",
"locatorEvidenceText": "如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。",
"citationId": "a1b2",
"normalizedFingerprint": "吡咯烷5h90",
"locator": {
"blockId": "block-pyrrolidine",
"openAction": {
"params": {
"evidenceText": "吡咯烷,5 h,90%"
}
}
},
"locatorDegraded": true
});
let result = knowledge_rag_search_result(&reference, 0, "file:///tmp/root", "吡咯烷");
assert_eq!(result["locatorPrecision"], "paragraph");
assert_eq!(result["locatorDegraded"], true);
assert_eq!(result["snippet"], "吡咯烷,5 h,90%");
assert_eq!(result["citationId"], "a1b2");
assert_eq!(
result["locatorEvidenceText"],
"如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。"
);
}
#[test]
fn citation_text_bundle_strips_equation_and_drawing_for_display() {
let bundle = citation_text_bundle(
Some(
r#"三乙基硅 <equation format="latex">{\left( {C}_{2}{H}_{5} \right)}_{3}</equation> <drawing id="im1" format="jpeg" /> </e"#,
),
Some("三乙基硅"),
None,
"chunk",
);
assert!(bundle.display_quote.contains("三乙基硅"));
assert!(!bundle.display_quote.contains("equation"));
assert!(!bundle.display_quote.contains("latex"));
assert!(!bundle.display_quote.contains("drawing"));
assert!(!bundle.display_quote.contains("</e"));
assert!(bundle.display_cleaned);
}
#[test]
fn citation_text_bundle_preserves_query_for_locator() {
let raw = "前置无关内容\n吡咯烷,5 h, 90%\n如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。\n参考文献";
let bundle = citation_text_bundle(Some(raw), Some("吡咯烷"), Some(raw), "sidecar");
assert!(bundle.locator_evidence_text.contains("吡咯烷"));
assert!(bundle.locator_evidence_text.contains("BOC 亚乙基"));
assert_eq!(bundle.search_query, "吡咯烷");
assert_eq!(bundle.locator_text_source, "sidecar_block");
assert!(!bundle.normalized_fingerprint.is_empty());
}
#[test]
fn knowledge_rag_citation_contains_unified_display_and_locator_text() {
let reference = json!({
"provider": "lightrag",
"citationId": "c0de",
"sourceId": "src1",
"sourceRootRelativePath": "docs/book.docx",
"filePath": "book.docx",
"chunkId": "chunk-1",
"displayQuote": "吡咯烷,5 h,90%",
"rawQuote": "<equation>raw</equation>",
"locatorEvidenceText": "如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。",
"searchQuery": "吡咯烷",
"locatorPrecision": "paragraph",
"locatorDegraded": true,
"locator": {"blockId": "block-pyrrolidine"},
"citationUrl": "/documents/local-md:docs~2FHost.md",
"citationMarkdown": "[book.docx](/documents/local-md:docs~2FHost.md)",
"citationDiagnostics": {"displayCleaned": true}
});
let citation = knowledge_rag_citation(&reference, 0).expect("citation");
assert_eq!(citation["citationId"], "c0de");
assert_eq!(citation["displayQuote"], "吡咯烷,5 h,90%");
assert_eq!(
citation["locatorEvidenceText"],
"如果叔丁基用 TFA 裂解, 吡咯烷将不会去除 BOC 亚乙基。"
);
assert_eq!(citation["locatorPrecision"], "paragraph");
}
#[test]
fn heading_path_for_sidecar_block_strips_lightrag_markup() {
let block = json!({
"parent_headings": ["保护", "<equation format=\"latex\">{R}_{2}</equation>"],
"heading": "二乙基胺加成物: <equation format=\"latex\">{NEt}_{2}</equation>"
});
let heading_path = heading_path_for_sidecar_block(Some(&block));
assert_eq!(heading_path[0], "保护");
assert!(!heading_path.join(" ").contains("equation"));
assert!(!heading_path.join(" ").contains("latex"));
assert!(heading_path.join(" ").contains("二乙基胺加成物"));
}
#[test]
fn provider_rerank_disabled_is_reported_in_status() {
let health = json!({
"configuration": {
"enable_rerank": false,
"rerank_binding": "null",
"rerank_model": Value::Null,
"min_rerank_score": 0.0
},
"rerank_queue_status": {
"available": false
}
});
let status = lightrag_rerank_status_summary(&health);
assert_eq!(status["enabled"], false);
assert_eq!(status["available"], false);
assert_eq!(status["providerRerankEnabled"], false);
assert_eq!(status["providerRerankAvailable"], false);
assert_eq!(status["status"], "disabled");
assert_eq!(status["implementation"], "provider_status_only");
}
#[test]
fn lightrag_search_dedupes_same_paragraph_block() {
let mut references = vec![
json!({
"sourceRootRelativePath": "docs/book.docx",
"chunkId": "chunk-056#match-0",
"matchSource": "lightrag_search",
"quote": "吡咯烷,5 h,90%",
"locator": {"blockId": "same-block"}
}),
json!({
"sourceRootRelativePath": "docs/book.docx",
"chunkId": "chunk-056#match-1",
"matchSource": "lightrag_search",
"quote": "吡咯烷,5 h,90%",
"locator": {"blockId": "same-block"}
}),
json!({
"sourceRootRelativePath": "docs/book.docx",
"chunkId": "chunk-057#match-0",
"matchSource": "lightrag_search",
"quote": "另一段吡咯烷",
"locator": {"blockId": "other-block"}
}),
];
dedupe_mapped_references_by_locator(&mut references);
assert_eq!(references.len(), 2);
assert_eq!(references[0]["locator"]["blockId"], "same-block");
assert_eq!(references[1]["locator"]["blockId"], "other-block");
}
#[test]
fn chunks_without_references_become_reference_candidates() {
let raw = json!({
"data": {
"chunks": [
{"file_path": "a.md", "reference_id": "1", "chunk_id": "chunk-a", "content": "A"},
{"file_path": "b.md", "reference_id": "2", "chunk_id": "chunk-b", "content": "B"}
]
}
});
let references = reference_array(&raw);
assert_eq!(references.len(), 2);
assert_eq!(references[0]["file_path"].as_str(), Some("a.md"));
assert!(references[0]["chunks"]
.as_array()
.is_some_and(|items| items.len() == 1));
}
#[test]
fn mapped_references_include_chunk_quote() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/papers/a.pdf".into(),
source_root_relative_path: "papers/a.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-a.pdf".into(),
symlink_path: "/tmp/input/mnote-hash-a.pdf".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"mnote-hash-a.pdf"}],
"chunks": [{
"reference_id":"1",
"chunk_id":"doc1-chunk-000",
"file_path":"mnote-hash-a.pdf",
"content":"chunk quote from LightRAG"
}]
}
});
let mapped = mapped_references(
&raw,
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
assert_eq!(mapped[0]["chunkId"], "doc1-chunk-000");
assert_eq!(mapped[0]["quote"], "chunk quote from LightRAG");
assert_eq!(mapped[0]["quoteSource"], "chunk");
assert_eq!(
mapped[0]["contentDiagnostics"]["ocrTextExposed"].as_bool(),
Some(true)
);
assert_eq!(
mapped[0]["openAction"]["params"]["chunkId"],
"doc1-chunk-000"
);
}
#[test]
fn mapped_references_expand_lightrag_chunks_in_same_source() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/books/a.docx".into(),
source_root_relative_path: "books/a.docx".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-a.docx".into(),
symlink_path: "/tmp/input/mnote-hash-a.docx".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"mnote-hash-a.docx"}],
"chunks": [
{
"reference_id":"1",
"chunk_id":"doc1-chunk-001",
"file_path":"mnote-hash-a.docx",
"content":"第一处 三甲基硅酯 内容"
},
{
"reference_id":"1",
"chunk_id":"doc1-chunk-002",
"file_path":"mnote-hash-a.docx",
"content":"第二处 三甲基硅基 内容"
}
]
}
});
let mut mapped = mapped_references(
&raw,
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
Some("三甲基硅"),
);
filter_mapped_references_by_search_query(&mut mapped, "三甲基硅");
rank_mapped_references_for_query(&mut mapped, "三甲基硅");
assert_eq!(mapped.len(), 2);
assert_eq!(mapped[0]["chunkId"], "doc1-chunk-001");
assert_eq!(mapped[1]["chunkId"], "doc1-chunk-002");
assert!(mapped[0]["quote"].as_str().unwrap().contains("三甲基硅"));
assert!(mapped[1]["quote"].as_str().unwrap().contains("三甲基硅"));
}
#[test]
fn search_query_filter_drops_unrelated_semantic_reference() {
let mut references = vec![
json!({
"sourceRootRelativePath": "books/protecting-groups.docx",
"quote": "# 三乙基硅酯(TES): RCOOSi"
}),
json!({
"sourceRootRelativePath": "images/image copy 6.png",
"quote": "CodePilot Bridge 处理过程截图,包含终端输出和飞书回复"
}),
];
filter_mapped_references_by_search_query(&mut references, "三乙基硅酯");
assert_eq!(references.len(), 1);
assert_eq!(
references[0]["sourceRootRelativePath"].as_str(),
Some("books/protecting-groups.docx")
);
}
#[test]
fn search_query_filter_keeps_cjk_natural_language_keyword_match() {
let mut references = vec![
json!({
"sourceRootRelativePath": "books/protecting-groups.docx",
"quote": "在此过程中吗啉被用作烯丙基的清除剂。"
}),
json!({
"sourceRootRelativePath": "books/other.docx",
"quote": "保护基资料包含用途说明,但没有目标化合物。"
}),
];
filter_mapped_references_by_search_query(&mut references, "吗啉 用途");
assert_eq!(references.len(), 1);
assert!(references[0]["quote"]
.as_str()
.unwrap_or_default()
.contains("吗啉"));
}
#[test]
fn mapped_image_placeholder_reference_marks_ocr_text_missing() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/docs/image.png".into(),
source_root_relative_path: "docs/image.png".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-image.png.md".into(),
symlink_path: "/tmp/input/mnote-hash-image.png.md".into(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({
"file_path": "mnote-hash-image.png.md",
"chunk_id": "doc1-chunk-000",
"chunks": [{
"chunk_id": "doc1-chunk-000",
"content": "# image.png\n\n![image.png](<mnote-hash-image.png>)"
}]
}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
assert_eq!(
mapped["contentDiagnostics"]["quoteOnlyImagePlaceholder"].as_bool(),
Some(true)
);
assert_eq!(
mapped["contentDiagnostics"]["ocrTextExposed"].as_bool(),
Some(false)
);
assert_eq!(mapped["quoteSource"], "chunk");
}
#[test]
fn mapped_reference_uses_chunk_query_window_for_late_cjk_match() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-late-cjk-query");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("book.docx"), b"docx").expect("docx");
let input_dir = root.join("inputs");
let parsed_dir = input_dir.join("__parsed__").join("book.docx.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("book.blocks.jsonl"),
[
r#"{"type":"meta","blocks":2}"#,
r##"{"type":"content","blockid":"wrong-index","content":"# 5.1.8 取代苄酯, 775 三苯甲基,775 二(邻硝基苯基)甲基,779","positions":[{"type":"bbox","anchor":"1","range":[1.0,2.0,3.0,4.0]}]}"##,
r##"{"type":"content","blockid":"tes","content":"# 三乙基硅酯(TES): RCOOSi <equation format=\"latex\">{\\left( {C}_{2}{H}_{5} \\right)}_{3}</equation>","positions":[{"type":"bbox","anchor":"2","range":[10.0,20.0,30.0,40.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("book.docx").display().to_string(),
source_root_relative_path: "docs/book.docx".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "book.docx".into(),
symlink_path: input_dir.join("book.docx").display().to_string(),
parser_hint: Some("native-P".into()),
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let chunk_content = format!(
"{}\n# 三乙基硅酯(TES): RCOOSi <equation format=\"latex\">{{\\left( {{C}}_{{2}}{{H}}_{{5}} \\right)}}_{{3}}</equation>\n# 保护",
"前置内容。".repeat(140)
);
assert!(query_centered_quote(&chunk_content, "三乙基硅", 500)
.unwrap()
.contains("三乙基硅酯"));
let mapped = map_reference_plan(
&json!({
"file_path": "book.docx",
"chunk_id": "doc1-chunk-256",
"chunks": [{
"chunk_id": "doc1-chunk-256",
2026-06-09 18:40:48 +08:00
"content": chunk_content,
"sidecar": {
"type": "block",
"refs": [
{"type": "block", "id": "wrong-index"},
{"type": "block", "id": "tes"}
]
}
}]
}),
&registry,
"file:///tmp/root",
&root,
Some("三乙基硅"),
);
assert!(mapped["quote"].as_str().unwrap().contains("三乙基硅酯"));
assert!(!mapped["quote"].as_str().unwrap().contains("取代苄酯"));
assert_eq!(mapped["quoteSource"], "chunk");
assert_eq!(mapped["locator"]["blockId"], "tes");
assert_eq!(mapped["locator"]["bbox"]["x0"].as_f64(), Some(10.0));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_content_diagnostics_reports_sidecar_ocr_text() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-source-diagnostics");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-image.png.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-image.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"线程作用域 OCR text","positions":[{"type":"bbox","anchor":"1","range":[1.0,2.0,3.0,4.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("image.png").display().to_string(),
source_root_relative_path: "image.png".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-image.png".into(),
symlink_path: input_dir.join("mnote-hash-image.png").display().to_string(),
parser_hint: Some("mineru".into()),
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let diagnostics = knowledge_rag_source_content_diagnostics(&registry);
assert_eq!(diagnostics[0]["ocrTextExposed"].as_bool(), Some(true));
assert_eq!(diagnostics[0]["sidecarMeaningfulBlocks"].as_u64(), Some(1));
assert_eq!(diagnostics[0]["directImageScan"].as_bool(), Some(true));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
2026-06-07 10:35:21 +08:00
#[test]
fn mapped_references_filters_unmapped_provider_references() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
2026-06-07 10:35:21 +08:00
entries: vec![],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"orphan.pdf"}],
"chunks": [{
"reference_id":"1",
"chunk_id":"orphan-chunk",
"file_path":"orphan.pdf",
"content":"orphan provider chunk"
}]
}
});
let mapped = mapped_references(
&raw,
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
);
2026-06-07 10:35:21 +08:00
assert!(
mapped.is_empty(),
"unmapped provider references must not become MNote citations"
);
let plan = map_reference_plan(
&json!({"file_path":"orphan.pdf","chunk_id":"orphan-chunk"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
None,
2026-06-07 10:35:21 +08:00
);
assert_eq!(plan["unmapped"], true);
assert_eq!(plan["locatorDegraded"], true);
}
#[test]
fn query_ranking_prefers_exact_source_and_quote_match() {
let mut references = vec![
json!({
"sourceRootRelativePath": "knowledge-rag-fixtures-7-50/paper-brotli-comparison.pdf",
"filePath": "mnote-paper-brotli-comparison.pdf",
"quote": "brotli compression table",
"citationMarkdown": "[paper-brotli-comparison.pdf · p.3](/)"
}),
json!({
"sourceRootRelativePath": "knowledge-rag-fixtures-7-50/scan-image-start.pdf",
"filePath": "mnote-scan-image-start.pdf",
"quote": "# START: This is the first image in PDF",
"citationMarkdown": "[scan-image-start.pdf · p.1](/)"
}),
];
rank_mapped_references_for_query(&mut references, "scan image start");
assert_eq!(
references[0]["sourceRootRelativePath"],
"knowledge-rag-fixtures-7-50/scan-image-start.pdf"
);
}
#[test]
fn query_mode_preserves_lightrag_native_modes() {
assert_eq!(normalize_lightrag_query_mode(Some("hybrid")), "hybrid");
assert_eq!(normalize_lightrag_query_mode(Some("global")), "global");
assert_eq!(normalize_lightrag_query_mode(Some("exact")), "naive");
assert_eq!(normalize_lightrag_query_mode(None), "mix");
assert_eq!(normalize_knowledge_rag_search_mode(Some("exact")), "exact");
assert_eq!(normalize_knowledge_rag_search_mode(Some("vector")), "naive");
assert_eq!(
normalize_knowledge_rag_search_mode(Some("hybrid")),
"hybrid"
);
assert_eq!(normalize_knowledge_rag_search_mode(None), "exact");
}
#[test]
fn symlink_name_preserves_parser_hint_before_extension() {
let name =
lightrag_symlink_name(Path::new("/tmp/books/demo.pdf"), "demo.pdf", Some("mineru"));
assert!(name.ends_with("-demo.[mineru].pdf"));
}
#[test]
fn symlink_name_preserves_parser_hint_with_options_before_extension() {
let name = lightrag_symlink_name(
Path::new("/tmp/books/demo.docx"),
"demo.docx",
Some("native-P"),
);
assert!(name.ends_with("-demo.[native-P].docx"));
assert!(strip_one_supported_parser_hint(&name)
.as_deref()
.unwrap_or_default()
.ends_with("-demo.docx"));
}
#[test]
fn ocr_layered_docx_defaults_to_native_paragraph_strategy() {
let file_name = "[OCR]_有机合成中的保护基_20250201.layered_删减-2025-02-04 18-59-42.docx";
assert_eq!(
default_lightrag_parser_hint_for_source(Path::new(file_name), file_name).as_deref(),
Some("native-P")
);
assert_eq!(
default_lightrag_parser_hint_for_source(Path::new("ordinary.docx"), "ordinary.docx"),
None
);
}
#[test]
fn symlink_name_does_not_duplicate_existing_parser_hint() {
let name = lightrag_symlink_name(
Path::new("/tmp/books/demo.[native].pdf"),
"demo.[native].pdf",
Some("native"),
);
assert!(name.ends_with("-demo.[native].pdf"));
assert!(!name.contains(".[native].[native]."));
}
#[test]
fn symlink_name_does_not_duplicate_existing_parser_hint_with_options() {
let name = lightrag_symlink_name(
Path::new("/tmp/books/demo.[native-P].docx"),
"demo.[native-P].docx",
Some("native-P"),
);
assert!(name.ends_with("-demo.[native-P].docx"));
assert!(!name.contains(".[native-P].[native-P]."));
}
#[test]
fn registry_file_path_matches_lightrag_hint_canonicalization() {
let entry = KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/scan.[mineru].pdf".into(),
source_root_relative_path: "scan.[mineru].pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: None,
light_rag_status: None,
light_rag_file_path: "mnote-hash-scan.[mineru].[mineru].pdf".into(),
symlink_path: "/tmp/input/mnote-hash-scan.[mineru].[mineru].pdf".into(),
parser_hint: Some("mineru".into()),
indexed_at_ms: None,
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
};
assert!(lightrag_file_path_matches(
&entry,
"mnote-hash-scan.[mineru].pdf"
));
}
#[test]
fn locator_uses_stripped_parser_hint_sidecar_path() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-sidecar");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-scan.pdf.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-scan.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"# START: This is the first image in PDF\nThis is text BEFORE the image.","positions":[{"type":"bbox","anchor":"1","range":[171.0,126.0,648.0,152.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let entry = KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/scan.pdf".into(),
source_root_relative_path: "scan.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-scan.[mineru].pdf".into(),
symlink_path: input_dir
.join("mnote-hash-scan.[mineru].pdf")
.display()
.to_string(),
parser_hint: Some("mineru".into()),
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
};
let locator = lightrag_locator_for_reference(
Path::new("/tmp/root"),
"file:///tmp/root",
&entry,
&json!("doc1-chunk-000"),
None,
None,
2026-06-09 18:40:48 +08:00
&json!(0),
Some(&json!({"type":"block","refs":[{"type":"block","id":"block1"}]})),
)
.expect("locator");
assert_eq!(locator.page, Some(1));
assert_eq!(locator.bbox.as_ref().map(|bbox| bbox.x0), Some(171.0));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn locator_exports_docx_paraid_positions_for_office_preview() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-docx-paraid");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-report.docx.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-report.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block-docx-1","content":"1.1 监管政策变革\n化妆品新原料监管政策变革内容。","positions":[{"type":"paraid","anchor":4,"range":["6692C49B","6692C49B"]},{"type":"text_fingerprint","anchor":"1454ec0b0ffd9fc0"}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let entry = KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: "/tmp/root/report.docx".into(),
source_root_relative_path: "report.docx".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-report.docx".into(),
symlink_path: input_dir
.join("mnote-hash-report.docx")
.display()
.to_string(),
parser_hint: Some("native".into()),
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
};
let locator = lightrag_locator_for_reference(
Path::new("/tmp/root"),
"file:///tmp/root",
&entry,
&json!("doc1-chunk-000"),
Some("监管政策变革"),
None,
2026-06-09 18:40:48 +08:00
&json!(0),
Some(&json!({"type":"block","refs":[{"type":"block","id":"block-docx-1"}]})),
)
.expect("locator");
assert_eq!(locator.resource_kind, EvidenceResourceKind::Office);
assert_eq!(locator.page, None);
assert_eq!(locator.bbox, None);
assert_eq!(locator.block_id.as_deref(), Some("block-docx-1"));
assert_eq!(
locator.open_action.params["paragraphOrdinal"].as_u64(),
Some(4)
);
assert_eq!(
locator.open_action.params["paraIdStart"].as_str(),
Some("6692C49B")
);
assert_eq!(
locator.open_action.params["textFingerprint"].as_str(),
Some("1454ec0b0ffd9fc0")
);
let citation_url = crate::routes::evidence::citation_url_for_locator(&locator);
assert!(citation_url.contains("paragraphOrdinal=4"));
assert!(citation_url.contains("paraIdStart=6692C49B"));
assert!(citation_url.contains("textFingerprint=1454ec0b0ffd9fc0"));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
2026-06-07 10:35:21 +08:00
let _ = fs::remove_dir_all(root);
}
#[test]
fn locator_degrades_when_sidecar_quote_does_not_match() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-sidecar-no-match");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("Host.md"), "# Host\n").expect("host");
fs::write(root.join("docs").join("scan.pdf"), b"pdf").expect("pdf");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-scan.pdf.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-scan.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"This block is not the returned quote.","positions":[{"type":"bbox","anchor":"9","range":[1.0,2.0,3.0,4.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
indexed_roots: Vec::new(),
2026-06-07 10:35:21 +08:00
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("scan.pdf").display().to_string(),
source_root_relative_path: "docs/scan.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-scan.pdf".into(),
symlink_path: input_dir.join("mnote-hash-scan.pdf").display().to_string(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({
"file_path": "mnote-hash-scan.pdf",
"chunk_id": "doc1-chunk-000",
"chunks": [{"chunk_id": "doc1-chunk-000", "content": "A different quote should not get page or bbox."}]
}),
&registry,
"file:///tmp/root",
&root,
None,
2026-06-07 10:35:21 +08:00
);
assert!(mapped["locator"].is_null());
assert_eq!(mapped["locatorDegraded"], true);
assert!(mapped["citationUrl"]
.as_str()
.is_some_and(|url| url.contains("resourceTab=")));
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn lightrag_paths_prefer_source_env_over_legacy_process_env() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-env");
let source_input = root.join("source-inputs");
let source_working = root.join("source-storage");
let legacy_input = root.join("legacy-inputs");
let legacy_working = root.join("legacy-storage");
let env_file = root.join("lightrag.env");
fs::write(
&env_file,
format!(
"INPUT_DIR={}\nWORKING_DIR={}\n",
source_input.display(),
source_working.display()
),
)
.expect("env file");
std::env::set_var("MNOTE_LIGHTRAG_ENV_FILE", &env_file);
std::env::set_var("INPUT_DIR", &legacy_input);
std::env::set_var("WORKING_DIR", &legacy_working);
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
std::env::remove_var("MNOTE_LIGHTRAG_WORKING_DIR");
assert_eq!(lightrag_input_dir(), source_input);
assert_eq!(lightrag_working_dir(), source_working);
std::env::remove_var("MNOTE_LIGHTRAG_ENV_FILE");
std::env::remove_var("INPUT_DIR");
std::env::remove_var("WORKING_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn directory_source_expands_supported_files_and_skips_internal_dirs() {
let root = temp_root("mnote-knowledge-rag-directory");
fs::create_dir_all(root.join("docs").join("nested")).expect("nested");
fs::create_dir_all(root.join(".mnote").join("index")).expect("mnote");
fs::write(root.join("docs").join("a.pdf"), b"%PDF").expect("pdf");
fs::write(root.join("docs").join("nested").join("b.md"), "# B").expect("md");
fs::write(root.join("docs").join("ignored.tmp"), "tmp").expect("tmp");
fs::write(
root.join(".mnote").join("index").join("hidden.pdf"),
b"%PDF",
)
.expect("hidden");
let sources =
resolve_knowledge_rag_sources(&root, "docs", &test_context()).expect("sources");
let rel = sources
.iter()
.map(|source| root_relative_path(&root, &source.canonical_path).expect("relative"))
.collect::<Vec<_>>();
assert_eq!(rel, vec!["docs/a.pdf", "docs/nested/b.md"]);
assert!(sources
.iter()
.all(|source| source.source_kind == "directory"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn file_source_rejects_unsupported_extension() {
let root = temp_root("mnote-knowledge-rag-unsupported");
fs::write(root.join("notes.tmp"), "tmp").expect("tmp");
let error = resolve_knowledge_rag_sources(&root, "notes.tmp", &test_context())
.expect_err("unsupported");
assert_eq!(error.code(), "knowledge_rag_source_unsupported");
let _ = fs::remove_dir_all(root);
}
}