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

404 lines
14 KiB
Rust

use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::Json;
use core_protocol::{
EvidenceLocator, EvidenceOpenRequest, EvidenceReadRequest, EvidenceSearchRequest,
};
use serde_json::{json, Value};
pub async fn search(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(_body): Json<EvidenceSearchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
Ok((
StatusCode::GONE,
headers,
Json(json!({
"ok": false,
"code": "mnote_evidence_search_retired",
"message": "旧 LiteParse/evidence 搜索已退役;PDF、Office、图片 OCR 与资料索引统一走 /api/knowledge-rag/query",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
))
}
pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
let label = citation_label_for_locator(locator);
let url = citation_url_for_locator(locator);
format!(
"[{}]({})",
markdown_link_label_escape(&label),
url.replace(')', "%29")
)
}
fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
let source_path = locator
.resource_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(locator.owner_document_path.as_str());
let name = source_path
.rsplit('/')
.find(|part| !part.trim().is_empty())
.unwrap_or(source_path)
.trim();
let mut parts = vec![if name.is_empty() {
"证据".to_string()
} else {
name.to_string()
}];
if let Some(page) = locator.page {
parts.push(format!("p.{page}"));
}
if let Some(section) = locator
.section_path
.last()
.filter(|value| !value.trim().is_empty())
{
parts.push(section.to_string());
}
parts.join(" · ")
}
pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
let owner_document_id = locator.owner_document_id.trim();
let mut url = if owner_document_id.is_empty() {
let existing = locator.open_action.url.trim();
if existing.is_empty() {
"/".to_string()
} else {
existing.to_string()
}
} else {
format!("/documents/{owner_document_id}")
};
append_query_param(&mut url, "sourceKind", "local_folder");
append_query_param(&mut url, "rootUri", locator.root_uri.trim());
if let Some(resource_path) = locator
.resource_path
.as_deref()
.filter(|value| !value.trim().is_empty())
{
append_query_param(
&mut url,
"resourceTab",
&format!(
"resource:file:{}:{}",
locator.root_uri.trim(),
resource_path
),
);
append_query_param(&mut url, "resourcePath", resource_path);
}
if let Some(page) = locator.page {
append_query_param(&mut url, "page", &page.to_string());
}
if let Some(bbox) = &locator.bbox {
append_query_param(
&mut url,
"bbox",
&format!("{},{},{},{}", bbox.x0, bbox.y0, bbox.x1, bbox.y1),
);
}
if let Some(block_id) = locator.block_id.as_deref() {
append_query_param(&mut url, "blockId", block_id);
}
if let Some(source_map_path) = locator.source_map_path.as_deref() {
append_query_param(&mut url, "sourceMapPath", source_map_path);
}
append_open_action_param(&mut url, &locator.open_action.params, "paragraphOrdinal");
append_open_action_param(&mut url, &locator.open_action.params, "paraIdStart");
append_open_action_param(&mut url, &locator.open_action.params, "paraIdEnd");
append_open_action_param(&mut url, &locator.open_action.params, "textFingerprint");
if let Some(query) = locator
.open_action
.params
.get("query")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
append_query_param(&mut url, "evidenceText", query);
}
if let Some(search_query) = locator
.open_action
.params
.get("searchQuery")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
append_query_param(&mut url, "searchQuery", search_query);
}
if let Some(line_range) = &locator.line_range {
append_query_param(
&mut url,
"lineRange",
&format!("{}-{}", line_range.start, line_range.end),
);
}
if let Some(char_range) = &locator.char_range {
append_query_param(
&mut url,
"charRange",
&format!("{}-{}", char_range.start, char_range.end),
);
}
url
}
fn append_open_action_param(url: &mut String, params: &Value, key: &str) {
let Some(value) = params.get(key) else {
return;
};
if let Some(text) = value.as_str() {
append_query_param(url, key, text);
} else if value.is_number() || value.is_boolean() {
append_query_param(url, key, &value.to_string());
}
}
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 markdown_link_label_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
pub async fn read(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(_body): Json<EvidenceReadRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
Ok((
StatusCode::GONE,
headers,
Json(json!({
"ok": false,
"code": "mnote_evidence_read_retired",
"message": "旧 evidence 读回已退役;Agent 引文打开请使用 /api/knowledge-rag/open-reference 或 citationUrl",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
))
}
pub async fn open(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceOpenRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let response = open_payload(&state, &context, body).await?;
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
Ok((StatusCode::OK, headers, Json(response)))
}
pub(crate) async fn open_payload(
state: &AppState,
context: &RequestContext,
body: EvidenceOpenRequest,
) -> Result<Value, WebError> {
let mut locator = body.locator;
let citation_url = citation_url_for_locator(&locator);
locator.open_action.url = citation_url.clone();
let root_uri = locator.root_uri.trim().to_string();
if root_uri.is_empty() {
return Err(
WebError::bad_request_code("evidence_root_required", "证据打开缺少 rootUri")
.with_context(context),
);
}
let _ = local_folder_source::ensure_local_workspace_read_access_with_state(
state, context, &root_uri,
)
.map_err(|error| error.with_context(context))?;
let response = json!({
"ok": true,
"locator": locator.clone(),
"openAction": locator.open_action,
"citationUrl": citation_url,
"citationLabel": citation_label_for_locator(&locator),
"citationMarkdown": citation_markdown_for_locator(&locator),
});
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use core_protocol::{EvidenceOpenAction, EvidenceResourceKind};
use tower::util::ServiceExt;
#[tokio::test]
async fn evidence_search_route_returns_retired_guard() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/evidence/search")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"query": "SqliteRouteToken",
"scope": {
"workspaceId": "local-ws-evidence-route",
"rootUri": "file:///workspace",
"targetDocumentId": "local-md:missing.md",
"includeResources": true,
"includeOcr": true
},
"mode": "hybrid",
"topK": 5
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(
payload["code"].as_str(),
Some("mnote_evidence_search_retired")
);
}
#[tokio::test]
async fn evidence_read_route_returns_retired_guard() {
let app = test_app();
let locator = sample_locator("file:///workspace");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/evidence/read")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"locator": locator
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], false);
assert_eq!(
payload["code"].as_str(),
Some("mnote_evidence_read_retired")
);
}
#[test]
fn evidence_citation_url_keeps_locator_bridge_params() {
let mut locator = sample_locator("file:///workspace");
locator.page = Some(3);
locator.block_id = Some("para-7".into());
locator.resource_path = Some("docs/Page.assets/spec.pdf".into());
locator.source_map_path = Some("docs/Page.assets/spec.source-map.json".into());
let url = citation_url_for_locator(&locator);
assert!(url.starts_with("/documents/local-md:docs~2FPage.md?"));
assert!(url.contains("sourceKind=local_folder"));
assert!(url.contains("rootUri=file%3A%2F%2F%2Fworkspace"));
assert!(url.contains("resourcePath=docs%2FPage.assets%2Fspec.pdf"));
assert!(url.contains("page=3"));
assert!(url.contains("blockId=para-7"));
}
fn sample_locator(root_uri: &str) -> EvidenceLocator {
EvidenceLocator::new(
root_uri,
"local-md:docs~2FPage.md",
"docs/Page.md",
EvidenceResourceKind::Pdf,
EvidenceOpenAction {
action_type: "mnote.open_resource_locator".into(),
url: "/documents/local-md:docs~2FPage.md".into(),
params: json!({"resourcePath":"docs/Page.assets/spec.pdf"}),
},
)
}
fn test_app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
}