421 lines
13 KiB
Rust
421 lines
13 KiB
Rust
use core_protocol::{
|
|
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind, SourceMapPage,
|
|
SourceMapSection, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
|
|
};
|
|
use std::fs;
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
use std::pin::Pin;
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParseCapability {
|
|
Unsupported,
|
|
Supported,
|
|
Preferred,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParseProviderMode {
|
|
Auto,
|
|
NoOcr,
|
|
Ocr,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ParseInput {
|
|
pub root_path: PathBuf,
|
|
pub root_uri: String,
|
|
pub owner_document_id: String,
|
|
pub owner_document_path: String,
|
|
pub source_root_relative_path: String,
|
|
pub mode: ParseProviderMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ParseProviderOutput {
|
|
pub artifact: ParsedResourceArtifact,
|
|
pub source_map: ResourceSourceMap,
|
|
pub markdown: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ParseError {
|
|
pub code: String,
|
|
pub message: String,
|
|
}
|
|
|
|
impl ParseError {
|
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: code.into(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait ParseProvider {
|
|
fn provider_id(&self) -> &'static str;
|
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability;
|
|
fn parse<'a>(
|
|
&'a self,
|
|
input: ParseInput,
|
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>>;
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct MarkdownParserProvider;
|
|
|
|
impl ParseProvider for MarkdownParserProvider {
|
|
fn provider_id(&self) -> &'static str {
|
|
"markdown"
|
|
}
|
|
|
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
|
if is_markdown_path(&input.source_root_relative_path) {
|
|
ParseCapability::Preferred
|
|
} else {
|
|
ParseCapability::Unsupported
|
|
}
|
|
}
|
|
|
|
fn parse<'a>(
|
|
&'a self,
|
|
input: ParseInput,
|
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
|
Box::pin(async move { parse_markdown_input(input) })
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct LiteParseProvider;
|
|
|
|
impl ParseProvider for LiteParseProvider {
|
|
fn provider_id(&self) -> &'static str {
|
|
"liteparse"
|
|
}
|
|
|
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
|
let _ = input;
|
|
ParseCapability::Unsupported
|
|
}
|
|
|
|
fn parse<'a>(
|
|
&'a self,
|
|
input: ParseInput,
|
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
|
Box::pin(async move { parse_liteparse_input(input).await })
|
|
}
|
|
}
|
|
|
|
pub fn default_parse_provider_id(input: &ParseInput) -> &'static str {
|
|
if is_markdown_path(&input.source_root_relative_path) {
|
|
"markdown"
|
|
} else {
|
|
"lightrag"
|
|
}
|
|
}
|
|
|
|
pub fn select_parse_provider_id(
|
|
input: &ParseInput,
|
|
native_text_confidence: Option<f64>,
|
|
) -> &'static str {
|
|
if is_markdown_path(&input.source_root_relative_path) {
|
|
return "markdown";
|
|
}
|
|
let _ = native_text_confidence;
|
|
"lightrag"
|
|
}
|
|
|
|
async fn parse_liteparse_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
|
let _ = input;
|
|
Err(ParseError::new(
|
|
"liteparse_retired",
|
|
"LiteParse 已退役;PDF、Office、图片 OCR 与资料索引统一由 LightRAG provider 处理",
|
|
))
|
|
}
|
|
|
|
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
|
let source_path = input.root_path.join(&input.source_root_relative_path);
|
|
let markdown = fs::read_to_string(&source_path).map_err(|error| {
|
|
ParseError::new(
|
|
"markdown_parse_read_failed",
|
|
format!(
|
|
"无法读取 Markdown 证据源 {}: {error}",
|
|
source_path.display()
|
|
),
|
|
)
|
|
})?;
|
|
let metadata = fs::metadata(&source_path).map_err(|error| {
|
|
ParseError::new(
|
|
"markdown_parse_stat_failed",
|
|
format!(
|
|
"无法读取 Markdown 证据源状态 {}: {error}",
|
|
source_path.display()
|
|
),
|
|
)
|
|
})?;
|
|
let updated_at_ms = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
|
.map(|value| value.as_millis() as u64)
|
|
.unwrap_or_default();
|
|
let source_hash = source_hash(&markdown, metadata.len(), updated_at_ms);
|
|
let source_map = markdown_source_map(&input, &markdown, &source_hash);
|
|
let artifact = ParsedResourceArtifact {
|
|
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
|
provider: "markdown".into(),
|
|
model_version: None,
|
|
owner_document_id: input.owner_document_id,
|
|
owner_document_path: input.owner_document_path,
|
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
|
source_hash,
|
|
artifact_root_relative_path: input.source_root_relative_path.clone(),
|
|
source_map_root_relative_path: format!(
|
|
"{}.source-map.json",
|
|
input.source_root_relative_path
|
|
),
|
|
updated_at_ms,
|
|
};
|
|
Ok(ParseProviderOutput {
|
|
artifact,
|
|
source_map,
|
|
markdown,
|
|
})
|
|
}
|
|
|
|
fn markdown_source_map(input: &ParseInput, markdown: &str, source_hash: &str) -> ResourceSourceMap {
|
|
let mut section_path: Vec<String> = Vec::new();
|
|
let mut sections = Vec::new();
|
|
let mut blocks = Vec::new();
|
|
let mut char_start = 0_u64;
|
|
for (index, line) in markdown.lines().enumerate() {
|
|
let line_number = (index + 1) as u64;
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
char_start += line.len() as u64 + 1;
|
|
continue;
|
|
}
|
|
let block_type = if let Some((level, title)) = markdown_heading(trimmed) {
|
|
section_path.truncate(level.saturating_sub(1));
|
|
section_path.push(title.clone());
|
|
let id = format!(
|
|
"sec_{}",
|
|
stable_segment(&format!(
|
|
"{}:{}",
|
|
input.source_root_relative_path,
|
|
section_path.join("/")
|
|
))
|
|
);
|
|
sections.push(SourceMapSection {
|
|
id,
|
|
title,
|
|
path: section_path.clone(),
|
|
page_start: Some(line_number as u32),
|
|
page_end: Some(line_number as u32),
|
|
block_ids: vec![format!("line{line_number}")],
|
|
});
|
|
SourceMapBlockKind::Heading
|
|
} else {
|
|
SourceMapBlockKind::Paragraph
|
|
};
|
|
blocks.push(SourceMapBlock {
|
|
id: format!("line{line_number}"),
|
|
block_type,
|
|
text: trimmed.to_string(),
|
|
bbox: None,
|
|
char_range: Some(core_protocol::EvidenceRange {
|
|
start: char_start,
|
|
end: char_start + line.len() as u64,
|
|
}),
|
|
});
|
|
char_start += line.len() as u64 + 1;
|
|
}
|
|
ResourceSourceMap {
|
|
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
|
provider: "markdown".into(),
|
|
model_version: None,
|
|
owner_document_path: input.owner_document_path.clone(),
|
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
|
source_hash: source_hash.to_string(),
|
|
page_count: Some(1),
|
|
pages: vec![SourceMapPage {
|
|
page: 1,
|
|
width: None,
|
|
height: None,
|
|
text_items: Vec::new(),
|
|
blocks,
|
|
}],
|
|
sections,
|
|
}
|
|
}
|
|
|
|
fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
|
let marker_count = line.chars().take_while(|value| *value == '#').count();
|
|
if marker_count == 0 || marker_count > 6 {
|
|
return None;
|
|
}
|
|
let rest = line.get(marker_count..)?.trim();
|
|
if rest.is_empty() {
|
|
return None;
|
|
}
|
|
Some((marker_count, rest.trim_matches('#').trim().to_string()))
|
|
}
|
|
|
|
fn is_markdown_path(path: &str) -> bool {
|
|
Path::new(path)
|
|
.extension()
|
|
.and_then(|value| value.to_str())
|
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
|
|
}
|
|
|
|
fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
|
|
format!(
|
|
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
|
|
fnv1a64(content.as_bytes())
|
|
)
|
|
}
|
|
|
|
fn fnv1a64(bytes: &[u8]) -> u64 {
|
|
let mut hash = 0xcbf29ce484222325_u64;
|
|
for byte in bytes {
|
|
hash ^= u64::from(*byte);
|
|
hash = hash.wrapping_mul(0x100000001b3);
|
|
}
|
|
hash
|
|
}
|
|
|
|
fn stable_segment(value: &str) -> String {
|
|
format!("{:016x}", fnv1a64(value.as_bytes()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn temp_root(name: &str) -> PathBuf {
|
|
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("docs")).expect("create root");
|
|
root
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn markdown_parser_provider_returns_artifact_and_source_map() {
|
|
let root = temp_root("mnote-markdown-parse-provider");
|
|
fs::write(
|
|
root.join("docs").join("Page.md"),
|
|
"# 合同\n正文\n## 解除条件\n提前三十日通知\n",
|
|
)
|
|
.expect("write markdown");
|
|
let input = ParseInput {
|
|
root_path: root.clone(),
|
|
root_uri: format!("file://{}", root.display()),
|
|
owner_document_id: "local-md:docs~2FPage.md".into(),
|
|
owner_document_path: "docs/Page.md".into(),
|
|
source_root_relative_path: "docs/Page.md".into(),
|
|
mode: ParseProviderMode::Auto,
|
|
};
|
|
let provider = MarkdownParserProvider;
|
|
assert_eq!(provider.can_parse(&input), ParseCapability::Preferred);
|
|
let output = provider.parse(input).await.expect("parse markdown");
|
|
|
|
assert_eq!(output.artifact.schema, PARSED_RESOURCE_ARTIFACT_SCHEMA);
|
|
assert_eq!(output.artifact.provider, "markdown");
|
|
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
|
|
assert_eq!(output.source_map.schema, RESOURCE_SOURCE_MAP_SCHEMA);
|
|
assert_eq!(output.source_map.sections.len(), 2);
|
|
assert!(output
|
|
.source_map
|
|
.sections
|
|
.iter()
|
|
.any(|section| section.path == vec!["合同".to_string(), "解除条件".to_string()]));
|
|
assert!(output.source_map.pages[0]
|
|
.blocks
|
|
.iter()
|
|
.any(|block| block.id == "line4" && block.text == "提前三十日通知"));
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn default_provider_routes_pdf_to_lightrag_after_liteparse_retirement() {
|
|
let input = ParseInput {
|
|
root_path: PathBuf::from("/workspace"),
|
|
root_uri: "file:///workspace".into(),
|
|
owner_document_id: "local-md:Page.md".into(),
|
|
owner_document_path: "Page.md".into(),
|
|
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
|
mode: ParseProviderMode::NoOcr,
|
|
};
|
|
let liteparse = LiteParseProvider;
|
|
assert_eq!(default_parse_provider_id(&input), "lightrag");
|
|
assert_eq!(liteparse.can_parse(&input), ParseCapability::Unsupported);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg(unix)]
|
|
async fn liteparse_provider_returns_retired_error() {
|
|
let root = temp_root("mnote-liteparse-provider");
|
|
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
|
fs::write(
|
|
root.join("docs").join("Page.assets").join("spec.pdf"),
|
|
b"%PDF-1.4",
|
|
)
|
|
.expect("pdf");
|
|
|
|
let input = ParseInput {
|
|
root_path: root.clone(),
|
|
root_uri: format!("file://{}", root.display()),
|
|
owner_document_id: "local-md:docs~2FPage.md".into(),
|
|
owner_document_path: "docs/Page.md".into(),
|
|
source_root_relative_path: "docs/Page.assets/spec.pdf".into(),
|
|
mode: ParseProviderMode::NoOcr,
|
|
};
|
|
let output = LiteParseProvider
|
|
.parse(input)
|
|
.await
|
|
.expect_err("liteparse retired");
|
|
|
|
assert_eq!(output.code, "liteparse_retired");
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn default_provider_routes_ocr_policy_to_lightrag() {
|
|
let input = ParseInput {
|
|
root_path: PathBuf::from("/workspace"),
|
|
root_uri: "file:///workspace".into(),
|
|
owner_document_id: "local-md:Page.md".into(),
|
|
owner_document_path: "Page.md".into(),
|
|
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
|
mode: ParseProviderMode::Ocr,
|
|
};
|
|
assert_eq!(default_parse_provider_id(&input), "lightrag");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_selection_routes_scanned_pdf_or_image_to_lightrag() {
|
|
let scanned_pdf = ParseInput {
|
|
root_path: PathBuf::from("/workspace"),
|
|
root_uri: "file:///workspace".into(),
|
|
owner_document_id: "local-md:Page.md".into(),
|
|
owner_document_path: "Page.md".into(),
|
|
source_root_relative_path: "Page.assets/scan.pdf".into(),
|
|
mode: ParseProviderMode::Auto,
|
|
};
|
|
assert_eq!(
|
|
select_parse_provider_id(&scanned_pdf, Some(0.05)),
|
|
"lightrag"
|
|
);
|
|
|
|
let image = ParseInput {
|
|
source_root_relative_path: "Page.assets/photo.png".into(),
|
|
..scanned_pdf
|
|
};
|
|
assert_eq!(select_parse_provider_id(&image, None), "lightrag");
|
|
}
|
|
}
|