feat: add evidence search and stabilize pdf previews
- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes - fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug - keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable Verification: - node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js - node scripts/task-dev-hot-plan-test.js - cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar - cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot - cargo build -p mnote-web --manifest-path rust/Cargo.toml - browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
@@ -8,6 +8,10 @@ use crate::routes::local_folder_source::{
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use core_protocol::{
|
||||
EvidenceBBox, EvidenceRange, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind,
|
||||
SourceMapPage, SourceMapSection, SourceMapTextItem, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -125,6 +129,7 @@ struct MineruZipAsset {
|
||||
struct MineruZipExtraction {
|
||||
markdown: String,
|
||||
assets: Vec<MineruZipAsset>,
|
||||
source_map_input: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -164,26 +169,6 @@ pub(crate) async fn create_job(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_PROVIDER);
|
||||
let token = if provider != "mock" {
|
||||
Some(mineru_token().ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mineru_token_missing",
|
||||
"缺少 MinerU API token",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if provider != "mock" && token.is_none() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mineru_token_missing",
|
||||
"缺少 MinerU API token",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let source_relative = body.source_root_relative_path.trim().replace('\\', "/");
|
||||
let _source_path_hint = body
|
||||
.source_path
|
||||
@@ -199,6 +184,30 @@ pub(crate) async fn create_job(
|
||||
DEFAULT_MODEL_VERSION,
|
||||
body.force,
|
||||
)?;
|
||||
if !body.force {
|
||||
if let Some(entry) = reusable_existing_ocr_entry(&state, &root, root_uri, &plan)? {
|
||||
return Ok(ok_json(
|
||||
&context,
|
||||
json!({
|
||||
"ok": true,
|
||||
"deduplicated": true,
|
||||
"job": ocr_job_payload(&root, &entry),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
let token = if provider != "mock" {
|
||||
Some(mineru_token().ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mineru_token_missing",
|
||||
"缺少 MinerU API token",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = now_ms();
|
||||
let mut entry = build_index_entry(&plan, "queued", now, "", None);
|
||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||
@@ -316,6 +325,10 @@ pub(crate) async fn delete_job(
|
||||
let removed = index.entries.remove(&source);
|
||||
if let Some(entry) = &removed {
|
||||
let sidecar = root.join(&entry.ocr_root_relative_path);
|
||||
let parse_sidecar = root.join(parse_sidecar_relative_path(&entry.ocr_root_relative_path));
|
||||
let source_map_sidecar = root.join(source_map_sidecar_relative_path(
|
||||
&entry.ocr_root_relative_path,
|
||||
));
|
||||
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
|
||||
if sidecar.exists() {
|
||||
fs::remove_file(&sidecar).map_err(|error| {
|
||||
@@ -326,6 +339,30 @@ pub(crate) async fn delete_job(
|
||||
.with_context(&context)
|
||||
})?;
|
||||
}
|
||||
if parse_sidecar.exists() {
|
||||
fs::remove_file(&parse_sidecar).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_delete_failed",
|
||||
format!(
|
||||
"无法删除 OCR Parse Markdown {}: {error}",
|
||||
parse_sidecar.display()
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
}
|
||||
if source_map_sidecar.exists() {
|
||||
fs::remove_file(&source_map_sidecar).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_delete_failed",
|
||||
format!(
|
||||
"无法删除 OCR source-map {}: {error}",
|
||||
source_map_sidecar.display()
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
}
|
||||
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
|
||||
}
|
||||
write_ocr_index(&root, &index)?;
|
||||
@@ -581,6 +618,10 @@ async fn run_mineru_ocr(
|
||||
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
|
||||
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
|
||||
write_mineru_zip_assets(plan, &extraction.assets)?;
|
||||
if let Some(source_map_input) = extraction.source_map_input.as_ref() {
|
||||
let source_map = build_mineru_source_map(plan, source_map_input);
|
||||
write_source_map_sidecar(plan, &source_map)?;
|
||||
}
|
||||
Ok(extraction.markdown)
|
||||
}
|
||||
|
||||
@@ -777,6 +818,7 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
||||
)
|
||||
})?;
|
||||
let mut candidates = Vec::<(String, String)>::new();
|
||||
let mut json_candidates = Vec::<(String, Value)>::new();
|
||||
let mut assets = Vec::<MineruZipAsset>::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive.by_index(index).map_err(|error| {
|
||||
@@ -800,6 +842,19 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
||||
candidates.push((name, markdown));
|
||||
continue;
|
||||
}
|
||||
if name.to_ascii_lowercase().ends_with(".json") {
|
||||
let mut json_text = String::new();
|
||||
file.read_to_string(&mut json_text).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"mineru_result_json_read_failed",
|
||||
format!("MinerU JSON 读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
if let Ok(value) = serde_json::from_str::<Value>(&json_text) {
|
||||
json_candidates.push((name, value));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
|
||||
continue;
|
||||
};
|
||||
@@ -830,7 +885,241 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
||||
"MinerU 结果包中缺少 Markdown 文件",
|
||||
)
|
||||
})?;
|
||||
Ok(MineruZipExtraction { markdown, assets })
|
||||
let source_map_input = pick_mineru_source_map_input(json_candidates);
|
||||
Ok(MineruZipExtraction {
|
||||
markdown,
|
||||
assets,
|
||||
source_map_input,
|
||||
})
|
||||
}
|
||||
|
||||
fn pick_mineru_source_map_input(candidates: Vec<(String, Value)>) -> Option<Value> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.max_by_key(|(name, value)| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let preferred = lower.ends_with("content_list.json")
|
||||
|| lower.ends_with("_content_list.json")
|
||||
|| lower.ends_with("middle.json");
|
||||
let item_count = mineru_content_items(value)
|
||||
.map(|items| items.len())
|
||||
.unwrap_or_default();
|
||||
(preferred, item_count)
|
||||
})
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
fn build_mineru_source_map(plan: &OcrSidecarPlan, value: &Value) -> ResourceSourceMap {
|
||||
let mut pages = BTreeMap::<u32, SourceMapPage>::new();
|
||||
let mut sections = Vec::new();
|
||||
let mut section_stack: Vec<String> = Vec::new();
|
||||
if let Some(items) = mineru_content_items(value) {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let page = mineru_page_number(item).unwrap_or(1);
|
||||
let text = mineru_item_text(item).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bbox = mineru_item_bbox(item);
|
||||
let block_id = format!("p{page}_b{}", index + 1);
|
||||
let text_item_id = format!("p{page}_t{}", index + 1);
|
||||
let block_type = mineru_block_kind(item);
|
||||
if matches!(block_type, SourceMapBlockKind::Heading) {
|
||||
let level = mineru_heading_level(item).unwrap_or(1).max(1);
|
||||
section_stack.truncate(level.saturating_sub(1));
|
||||
section_stack.push(text.clone());
|
||||
sections.push(SourceMapSection {
|
||||
id: format!(
|
||||
"sec_{}",
|
||||
short_hash(&format!(
|
||||
"{}:{}:{}",
|
||||
plan.source_root_relative_path,
|
||||
page,
|
||||
section_stack.join("/")
|
||||
))
|
||||
),
|
||||
title: text.clone(),
|
||||
path: section_stack.clone(),
|
||||
page_start: Some(page),
|
||||
page_end: Some(page),
|
||||
block_ids: vec![block_id.clone()],
|
||||
});
|
||||
} else if let Some(section) = sections.last_mut() {
|
||||
section.page_end = Some(page);
|
||||
if !section.block_ids.iter().any(|id| id == &block_id) {
|
||||
section.block_ids.push(block_id.clone());
|
||||
}
|
||||
}
|
||||
let page_entry = pages.entry(page).or_insert_with(|| SourceMapPage {
|
||||
page,
|
||||
width: None,
|
||||
height: None,
|
||||
text_items: Vec::new(),
|
||||
blocks: Vec::new(),
|
||||
});
|
||||
page_entry.text_items.push(SourceMapTextItem {
|
||||
id: text_item_id,
|
||||
text: text.clone(),
|
||||
bbox: bbox.clone(),
|
||||
char_range: Some(EvidenceRange {
|
||||
start: 0,
|
||||
end: text.chars().count() as u64,
|
||||
}),
|
||||
});
|
||||
page_entry.blocks.push(SourceMapBlock {
|
||||
id: block_id,
|
||||
block_type,
|
||||
text: text.clone(),
|
||||
bbox,
|
||||
char_range: Some(EvidenceRange {
|
||||
start: 0,
|
||||
end: text.chars().count() as u64,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
ResourceSourceMap {
|
||||
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||
provider: plan.provider.clone(),
|
||||
model_version: Some(plan.model_version.clone()),
|
||||
owner_document_path: plan.owner_document_path.clone(),
|
||||
source_root_relative_path: plan.source_root_relative_path.clone(),
|
||||
source_hash: format!("size:{}:mtime:{}", plan.source_size, plan.source_mtime_ms),
|
||||
page_count: pages.keys().max().copied(),
|
||||
pages: pages.into_values().collect(),
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_source_map_sidecar(
|
||||
plan: &OcrSidecarPlan,
|
||||
source_map: &ResourceSourceMap,
|
||||
) -> Result<(), WebError> {
|
||||
let source_map_path = source_map_path_for_ocr_plan(plan);
|
||||
if let Some(parent) = source_map_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_source_map_create_failed",
|
||||
format!("无法创建 source-map 目录 {}: {error}", parent.display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(source_map).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_source_map_serialize_failed",
|
||||
format!("source-map 序列化失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
fs::write(&source_map_path, content).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_source_map_write_failed",
|
||||
format!("无法写入 source-map {}: {error}", source_map_path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn source_map_path_for_ocr_plan(plan: &OcrSidecarPlan) -> PathBuf {
|
||||
let source_map_relative = source_map_sidecar_relative_path(&plan.ocr_root_relative_path);
|
||||
let file_name = Path::new(&source_map_relative)
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("source.source-map.json"));
|
||||
plan.ocr_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join(file_name)
|
||||
}
|
||||
|
||||
fn parse_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
|
||||
ocr_root_relative_path
|
||||
.strip_suffix(".ocr.md")
|
||||
.map(|prefix| format!("{prefix}.parse.md"))
|
||||
.unwrap_or_else(|| format!("{ocr_root_relative_path}.parse.md"))
|
||||
}
|
||||
|
||||
fn source_map_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
|
||||
ocr_root_relative_path
|
||||
.strip_suffix(".ocr.md")
|
||||
.map(|prefix| format!("{prefix}.source-map.json"))
|
||||
.unwrap_or_else(|| format!("{ocr_root_relative_path}.source-map.json"))
|
||||
}
|
||||
|
||||
fn mineru_content_items(value: &Value) -> Option<Vec<Value>> {
|
||||
match value {
|
||||
Value::Array(items) => Some(items.clone()),
|
||||
Value::Object(map) => {
|
||||
for key in ["content_list", "contentList", "items", "blocks", "pages"] {
|
||||
if let Some(items) = map.get(key).and_then(mineru_content_items) {
|
||||
return Some(items);
|
||||
}
|
||||
}
|
||||
map.values().find_map(mineru_content_items)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mineru_item_text(value: &Value) -> Option<String> {
|
||||
for key in ["text", "content", "markdown", "md"] {
|
||||
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mineru_page_number(value: &Value) -> Option<u32> {
|
||||
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
|
||||
return u32::try_from(page_idx + 1).ok();
|
||||
}
|
||||
for key in ["page", "page_no", "pageNo", "page_number", "pageNumber"] {
|
||||
if let Some(page) = value.get(key).and_then(Value::as_u64) {
|
||||
return u32::try_from(page.max(1)).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mineru_item_bbox(value: &Value) -> Option<EvidenceBBox> {
|
||||
let bbox = value.get("bbox").and_then(Value::as_array)?;
|
||||
if bbox.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(EvidenceBBox {
|
||||
x0: bbox[0].as_f64()?,
|
||||
y0: bbox[1].as_f64()?,
|
||||
x1: bbox[2].as_f64()?,
|
||||
y1: bbox[3].as_f64()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn mineru_block_kind(value: &Value) -> SourceMapBlockKind {
|
||||
match value
|
||||
.get("type")
|
||||
.or_else(|| value.get("block_type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"title" | "heading" => SourceMapBlockKind::Heading,
|
||||
"table" => SourceMapBlockKind::Table,
|
||||
"image" => SourceMapBlockKind::Image,
|
||||
"figure" => SourceMapBlockKind::Figure,
|
||||
"list" => SourceMapBlockKind::List,
|
||||
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
|
||||
_ => SourceMapBlockKind::Text,
|
||||
}
|
||||
}
|
||||
|
||||
fn mineru_heading_level(value: &Value) -> Option<usize> {
|
||||
value
|
||||
.get("level")
|
||||
.or_else(|| value.pointer("/props/level"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
}
|
||||
|
||||
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
|
||||
@@ -991,7 +1280,7 @@ fn plan_ocr_sidecar_path(
|
||||
source_root_relative_path: &str,
|
||||
provider: &str,
|
||||
model_version: &str,
|
||||
force: bool,
|
||||
_force: bool,
|
||||
) -> Result<OcrSidecarPlan, WebError> {
|
||||
let owner_document_path = owner_document_path_from_id(document_id)?;
|
||||
let source_root_relative_path = normalize_relative_path(source_root_relative_path)?;
|
||||
@@ -1049,15 +1338,7 @@ fn plan_ocr_sidecar_path(
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("source");
|
||||
let base_file_name = format!("{source_leaf}.ocr.md");
|
||||
let mut ocr_relative = ocr_dir.join(&base_file_name);
|
||||
let default_path = root.join(&ocr_relative);
|
||||
if !force && default_path.exists() {
|
||||
let suffix = short_hash(&format!(
|
||||
"{}:{}:{}",
|
||||
source_root_relative_path, source_metadata.size, source_metadata.mtime_ms
|
||||
));
|
||||
ocr_relative = ocr_dir.join(format!("{source_leaf}-{suffix}.ocr.md"));
|
||||
}
|
||||
let ocr_relative = ocr_dir.join(&base_file_name);
|
||||
let ocr_root_relative_path = ocr_relative.to_string_lossy().replace('\\', "/");
|
||||
let ocr_path = root.join(&ocr_root_relative_path);
|
||||
ensure_target_under_root(root, &ocr_path, "local_ocr_sidecar_root_escape")?;
|
||||
@@ -1086,6 +1367,20 @@ fn write_ocr_sidecar(
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let parse_relative = parse_sidecar_relative_path(&plan.ocr_root_relative_path);
|
||||
let parse_file_name = Path::new(&parse_relative)
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("source.parse.md"));
|
||||
let parse_path = plan.ocr_path.with_file_name(parse_file_name);
|
||||
fs::write(&parse_path, markdown_body.trim_end()).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_sidecar_write_failed",
|
||||
format!(
|
||||
"无法写入 OCR Parse Markdown {}: {error}",
|
||||
parse_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let content = build_ocr_markdown(plan, markdown_body, "done", now);
|
||||
fs::write(&plan.ocr_path, content).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -1220,6 +1515,93 @@ fn write_ocr_index(root: &Path, index: &OcrIndex) -> Result<(), WebError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn reusable_existing_ocr_entry(
|
||||
state: &AppState,
|
||||
root: &Path,
|
||||
root_uri: &str,
|
||||
plan: &OcrSidecarPlan,
|
||||
) -> Result<Option<OcrIndexEntry>, WebError> {
|
||||
let index = read_ocr_index(root)?;
|
||||
if let Some(entry) = index.entries.get(&plan.source_root_relative_path) {
|
||||
if entry.source_size == plan.source_size && entry.source_mtime_ms == plan.source_mtime_ms {
|
||||
let status = entry.status.as_str();
|
||||
if status == "done" {
|
||||
let sidecar = root.join(&entry.ocr_root_relative_path);
|
||||
if sidecar.is_file() && !source_is_stale(root, entry) {
|
||||
return Ok(Some(entry.clone()));
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
status,
|
||||
"queued" | "uploading" | "mineru_processing" | "downloading" | "writing_sidecar"
|
||||
) {
|
||||
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
|
||||
if state
|
||||
.local_ocr_active_jobs
|
||||
.read()
|
||||
.map(|jobs| jobs.contains_key(&key))
|
||||
.unwrap_or(false)
|
||||
&& !source_is_stale(root, entry)
|
||||
{
|
||||
return Ok(Some(entry.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let default_ocr_path = root.join(&plan.ocr_root_relative_path);
|
||||
if default_ocr_path.is_file() {
|
||||
let markdown = fs::read_to_string(&default_ocr_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_ocr_read_failed",
|
||||
format!(
|
||||
"无法读取 OCR Markdown {}: {error}",
|
||||
default_ocr_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if let Some(frontmatter) = parse_ocr_frontmatter(&markdown) {
|
||||
if frontmatter.source_root_relative_path == plan.source_root_relative_path
|
||||
&& frontmatter.source_size == plan.source_size
|
||||
&& frontmatter.source_mtime_ms == plan.source_mtime_ms
|
||||
&& frontmatter.status == "done"
|
||||
{
|
||||
let now = now_ms();
|
||||
let entry = OcrIndexEntry {
|
||||
job_id: format!(
|
||||
"ocr_{}_{}",
|
||||
now,
|
||||
short_hash(&plan.source_root_relative_path)
|
||||
),
|
||||
owner_document_id: format!(
|
||||
"local-md:{}",
|
||||
encode_local_id_segment(&plan.owner_document_path)
|
||||
),
|
||||
owner_document_path: plan.owner_document_path.clone(),
|
||||
source_root_relative_path: plan.source_root_relative_path.clone(),
|
||||
ocr_root_relative_path: plan.ocr_root_relative_path.clone(),
|
||||
provider: frontmatter.provider,
|
||||
model_version: plan.model_version.clone(),
|
||||
status: frontmatter.status,
|
||||
source_size: plan.source_size,
|
||||
source_mtime_ms: plan.source_mtime_ms,
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
plain_text_preview: strip_ocr_frontmatter(&markdown)
|
||||
.chars()
|
||||
.take(240)
|
||||
.collect(),
|
||||
error: None,
|
||||
};
|
||||
upsert_ocr_index_entry(root, entry.clone())?;
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebError> {
|
||||
let mut index = read_ocr_index(root)?;
|
||||
index.version = OCR_INDEX_VERSION;
|
||||
@@ -1787,6 +2169,11 @@ mod tests {
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.ocr.md")
|
||||
.is_file());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.parse.md")
|
||||
.is_file());
|
||||
|
||||
let escaped_root = query_escape(&root_uri);
|
||||
let status_response = app()
|
||||
@@ -1855,6 +2242,11 @@ mod tests {
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.ocr.md")
|
||||
.exists());
|
||||
assert!(!root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.parse.md")
|
||||
.exists());
|
||||
assert!(read_ocr_index(&root)
|
||||
.expect("index after delete")
|
||||
.entries
|
||||
@@ -1862,6 +2254,235 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_reuses_existing_done_sidecar_without_reprocessing() {
|
||||
let root = temp_root("mnote-local-ocr-dedup-done");
|
||||
write_workspace_manifest(&root);
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let create_payload = |markdown: &str| {
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:docs~2FPage.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"provider": "mock",
|
||||
"mockMarkdown": markdown
|
||||
})
|
||||
};
|
||||
let first_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(create_payload("First OCR Token").to_string()))
|
||||
.expect("first request"),
|
||||
)
|
||||
.await
|
||||
.expect("first response");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
let first_body = to_bytes(first_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("first body");
|
||||
let first_payload: Value = serde_json::from_slice(&first_body).expect("first json");
|
||||
assert_eq!(first_payload["job"]["status"].as_str(), Some("done"));
|
||||
|
||||
let second_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(create_payload("Second OCR Token").to_string()))
|
||||
.expect("second request"),
|
||||
)
|
||||
.await
|
||||
.expect("second response");
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
let second_body = to_bytes(second_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("second body");
|
||||
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
|
||||
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
second_payload["job"]["ocrRootRelativePath"].as_str(),
|
||||
Some("docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
let sidecar =
|
||||
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||
.expect("sidecar");
|
||||
assert!(sidecar.contains("First OCR Token"));
|
||||
assert!(!sidecar.contains("Second OCR Token"));
|
||||
assert!(!root
|
||||
.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png-")
|
||||
.exists());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_recovers_existing_done_sidecar_when_index_is_missing() {
|
||||
let root = temp_root("mnote-local-ocr-dedup-sidecar-recover");
|
||||
write_workspace_manifest(&root);
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let create_payload = |markdown: &str| {
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:docs~2FPage.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"provider": "mock",
|
||||
"mockMarkdown": markdown
|
||||
})
|
||||
};
|
||||
let first_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
create_payload("Recovered OCR Token").to_string(),
|
||||
))
|
||||
.expect("first request"),
|
||||
)
|
||||
.await
|
||||
.expect("first response");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
fs::remove_file(ocr_index_path(&root)).expect("remove index");
|
||||
|
||||
let second_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
create_payload("Should Not Reprocess").to_string(),
|
||||
))
|
||||
.expect("second request"),
|
||||
)
|
||||
.await
|
||||
.expect("second response");
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
let second_body = to_bytes(second_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("second body");
|
||||
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
|
||||
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
read_ocr_index(&root)
|
||||
.expect("recovered index")
|
||||
.entries
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let sidecar =
|
||||
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||
.expect("sidecar");
|
||||
assert!(sidecar.contains("Recovered OCR Token"));
|
||||
assert!(!sidecar.contains("Should Not Reprocess"));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_reuses_existing_done_before_mineru_token_check() {
|
||||
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
|
||||
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
|
||||
std::env::remove_var("MNOTE_MINERU_API_TOKEN");
|
||||
std::env::remove_var("MINERU_API_TOKEN");
|
||||
|
||||
let root = temp_root("mnote-local-ocr-dedup-before-token");
|
||||
write_workspace_manifest(&root);
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let first_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:docs~2FPage.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"provider": "mock",
|
||||
"mockMarkdown": "Existing OCR Token"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("first request"),
|
||||
)
|
||||
.await
|
||||
.expect("first response");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
|
||||
let second_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/local-folder/ocr/jobs")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:docs~2FPage.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"provider": "mineru"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("second request"),
|
||||
)
|
||||
.await
|
||||
.expect("second response");
|
||||
|
||||
if let Some(value) = old_mnote_token {
|
||||
std::env::set_var("MNOTE_MINERU_API_TOKEN", value);
|
||||
}
|
||||
if let Some(value) = old_mineru_token {
|
||||
std::env::set_var("MINERU_API_TOKEN", value);
|
||||
}
|
||||
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(second_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("second body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("second json");
|
||||
assert_eq!(payload["deduplicated"].as_bool(), Some(true));
|
||||
assert_eq!(payload["job"]["status"].as_str(), Some("done"));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
|
||||
let root = temp_root("mnote-local-ocr-events");
|
||||
@@ -1935,6 +2556,12 @@ mod tests {
|
||||
|
||||
let root = temp_root("mnote-local-ocr-token");
|
||||
write_workspace_manifest(&root);
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let (status, payload) = post_ocr_job(
|
||||
&root,
|
||||
@@ -1966,7 +2593,14 @@ mod tests {
|
||||
let poll_count = Arc::new(AtomicUsize::new(0));
|
||||
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
|
||||
"# MinerU Result\n\n\n\n识别文本",
|
||||
&[("images/ocr.png", b"png-bytes")],
|
||||
&[
|
||||
("images/ocr.png", b"png-bytes"),
|
||||
(
|
||||
"content_list.json",
|
||||
r#"[{"type":"title","level":1,"page_idx":0,"text":"MinerU Result","bbox":[0,0,100,18]},{"type":"text","page_idx":0,"text":"识别文本","bbox":[10,20,110,40]}]"#
|
||||
.as_bytes(),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let mock_mineru = axum::Router::new()
|
||||
@@ -2114,6 +2748,30 @@ mod tests {
|
||||
assert!(sidecar.contains("provider: mineru"));
|
||||
assert!(sidecar.contains(""));
|
||||
assert!(sidecar.contains("识别文本"));
|
||||
let parse_markdown = fs::read_to_string(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.parse.md"),
|
||||
)
|
||||
.expect("parse markdown");
|
||||
assert_eq!(
|
||||
parse_markdown.trim(),
|
||||
"# MinerU Result\n\n\n\n识别文本"
|
||||
);
|
||||
let source_map = fs::read_to_string(
|
||||
root.join("docs")
|
||||
.join("Page.ocr")
|
||||
.join("photo.png.source-map.json"),
|
||||
)
|
||||
.expect("source map");
|
||||
let source_map_json: Value = serde_json::from_str(&source_map).expect("source map json");
|
||||
assert_eq!(source_map_json["schema"], RESOURCE_SOURCE_MAP_SCHEMA);
|
||||
assert_eq!(source_map_json["provider"], "mineru");
|
||||
assert_eq!(source_map_json["pages"][0]["page"], 1);
|
||||
assert_eq!(source_map_json["pages"][0]["blocks"][1]["text"], "识别文本");
|
||||
assert_eq!(source_map_json["pages"][0]["blocks"][1]["bbox"]["x0"], 10.0);
|
||||
assert_eq!(source_map_json["sections"][0]["path"][0], "MinerU Result");
|
||||
assert_eq!(source_map_json["sections"][0]["blockIds"][1], "p1_b2");
|
||||
assert_eq!(
|
||||
fs::read(
|
||||
root.join("docs")
|
||||
|
||||
Reference in New Issue
Block a user