From 3bf102541da87ccb5d4e3964410cb005cc2d1dc5 Mon Sep 17 00:00:00 2001 From: cncases Date: Fri, 24 Jul 2026 18:22:33 +0800 Subject: [PATCH 1/6] api/search --- Cargo.lock | 18 +++---- src/bin/main.rs | 5 +- src/controller.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 2 +- 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d2e2a6..3792248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2999,9 +2999,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr3" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" +checksum = "be5bfc63c4dc85083c9daaf7112d0261701d4058677c3bff7f2afc44e30ef3e1" dependencies = [ "proc-macro2", "quote", @@ -3009,14 +3009,14 @@ dependencies = [ [[package]] name = "proc-macro-error3" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" +checksum = "dd0d42490f6b7b143eef32b9e3522e42bf25dadc02c69ed72236f80adb949b5c" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3478,9 +3478,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -4279,9 +4279,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", diff --git a/src/bin/main.rs b/src/bin/main.rs index 5c22d28..5fa8314 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,5 +1,7 @@ use axum::{Router, http::StatusCode, routing::get}; -use cases::{AppState, CONFIG, Tan, case, help, kv_sep_partition_option, search, style}; +use cases::{ + AppState, CONFIG, Tan, api_search, case, help, kv_sep_partition_option, search, style, +}; use fjall::Config; use std::{net::SocketAddr, sync::Arc, time::Duration}; @@ -58,6 +60,7 @@ async fn main() { .route("/case/{id}", get(case)) .route("/style.css", get(style)) .route("/help.txt", get(help)) + .route("/api/search", get(api_search)) .layer(middleware_stack) .with_state(app_state); diff --git a/src/controller.rs b/src/controller.rs index d9d37ce..bbc5eee 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -1,5 +1,6 @@ use askama::Template; use axum::{ + Json, body::Body, extract::{Path, Query, State}, http::{Response, StatusCode, header}, @@ -7,7 +8,7 @@ use axum::{ }; use bincode::config::standard; use indexmap::IndexSet; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::LazyLock; use tantivy::{ DocAddress, Score, TantivyDocument, @@ -316,6 +317,135 @@ pub async fn search( into_response(&body) } +#[derive(Debug, Serialize)] +struct CaseDetail { + id: u32, + case: Case, +} + +pub async fn api_search( + Query(input): Query, + State(state): State, +) -> impl IntoResponse { + let mut offset = input.offset.unwrap_or_default(); + if offset > *MAX_RESULTS { + offset = *MAX_RESULTS + } + let search = input.search.unwrap_or_default(); + let search_type = + if cfg!(feature = "vsearch") && input.search_type.as_deref() == Some("vsearch") { + "vsearch".to_owned() + } else { + "keyword".to_owned() + }; + let limit = 20; + let mut ids: IndexSet = IndexSet::with_capacity(20); + let mut total = 0; + if !search.trim().is_empty() { + let now = std::time::Instant::now(); + let search = fast2s::convert(&search); + if search_type == "keyword" { + let (query, _) = state.searcher.query_parser.parse_query_lenient(&search); + let searcher = state.searcher.reader.searcher(); + total = searcher.search(&query, &Count).unwrap(); + + let top_docs: Vec<(Score, DocAddress)> = searcher + .search( + &query, + &TopDocs::with_limit(limit) + .and_offset(offset) + .order_by_score(), + ) + .unwrap_or_default(); + + for (_score, doc_address) in top_docs { + if let Some(id) = searcher + .doc::(doc_address) + .unwrap() + .get_first(state.searcher.id) + .unwrap() + .as_u64() + { + ids.insert(id as u32); + } + } + } else { + #[cfg(feature = "vsearch")] + if search_type == "vsearch" { + { + let query_vec = MODEL.lock().unwrap().embed(vec![&search], None).unwrap(); + let client = state.qclient; + let search_limit = limit + offset; + total = search_limit; + if let Ok(search_result) = client + .search_points( + SearchPointsBuilder::new( + &CONFIG.collection_name, + query_vec.into_iter().next().unwrap(), + search_limit as u64, + ) + .with_payload(false) + .limit(limit as u64) + .offset(offset as u64), + ) + .await + { + for point in &search_result.result { + let id = point + .id + .as_ref() + .unwrap() + .point_id_options + .as_ref() + .unwrap(); + if let PointIdOptions::Num(id) = id { + ids.insert(*id as u32); + } + } + } else { + tracing::error!("Qdrant search_points failed"); + } + } + } + } + + let elapsed = now.elapsed().as_secs_f32(); + + info!( + "api search {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s " + ); + } + + let mut cases = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(v) = state.db.get(id.to_be_bytes()).unwrap() { + let (case, _): (Case, _) = bincode::decode_from_slice(&v, standard()).unwrap(); + let case_detail = CaseDetail { id, case }; + cases.push(case_detail); + } + } + let search_data = SearchData { + search, + search_type, + offset, + cases, + total, + enable_vsearch: cfg!(feature = "vsearch"), + }; + + Json(search_data).into_response() +} + +#[derive(Serialize)] +pub struct SearchData { + search: String, + offset: usize, + total: usize, + search_type: String, + enable_vsearch: bool, + cases: Vec, +} + pub async fn style() -> impl IntoResponse { let headers = [ (header::CONTENT_TYPE, "text/css"), diff --git a/src/lib.rs b/src/lib.rs index c6e9bf2..d87a722 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ use bincode::{Decode, Encode}; pub use config::CONFIG; -pub use controller::{case, help, search, style}; +pub use controller::{api_search, case, help, search, style}; use fjall::{KvSeparationOptions, PartitionCreateOptions, PartitionHandle}; use scraper::Html; use serde::{Deserialize, Serialize}; From 9f26ccc30a9134ee46052bb29dab09508cdad34a Mon Sep 17 00:00:00 2001 From: cncases Date: Sat, 25 Jul 2026 10:59:57 +0800 Subject: [PATCH 2/6] Refactor search functionality to use SearchMeta struct and update template bindings --- src/controller.rs | 245 ++++++++++++++++-------------------------- templates/search.html | 34 +++--- 2 files changed, 107 insertions(+), 172 deletions(-) diff --git a/src/controller.rs b/src/controller.rs index bbc5eee..e77a4a9 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -124,12 +124,8 @@ pub struct QuerySearch { #[derive(Template)] #[template(path = "search.html")] pub struct SearchPage { - search: String, - offset: usize, - total: usize, - search_type: String, - enable_vsearch: bool, - cases: Vec<(u32, String, Case)>, + search_meta: SearchMeta, + cases: Vec, } #[cfg(feature = "vsearch")] @@ -143,10 +139,22 @@ static MODEL: LazyLock> = LazyLock::new(|| { Mutex::new(model) }); -pub async fn search( +#[derive(Serialize)] +struct SearchMeta { + offset: usize, + search: String, + search_type: String, + limit: usize, + total: usize, + export: bool, + enable_vsearch: bool, +} + +async fn search_cases( Query(input): Query, State(state): State, -) -> impl IntoResponse { +) -> (Vec, SearchMeta) { + let now = std::time::Instant::now(); let mut offset = input.offset.unwrap_or_default(); if offset > *MAX_RESULTS { offset = *MAX_RESULTS @@ -163,7 +171,6 @@ pub async fn search( let mut ids: IndexSet = IndexSet::with_capacity(20); let mut total = 0; if !search.trim().is_empty() { - let now = std::time::Instant::now(); let search = fast2s::convert(&search); if search_type == "keyword" { let (query, _) = state.searcher.query_parser.parse_query_lenient(&search); @@ -229,17 +236,6 @@ pub async fn search( } } } - - let elapsed = now.elapsed().as_secs_f32(); - if export { - info!( - "export {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s" - ); - } else { - info!( - "search {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s " - ); - } } let mut cases = Vec::with_capacity(ids.len()); @@ -250,13 +246,59 @@ pub async fn search( .chars() .take(240) .collect(); - cases.push((id, preview, case)); + let case_data = CaseData { + id, + preview, + doc_id: case.doc_id, + case_id: case.case_id, + case_name: case.case_name, + court: case.court, + case_type: case.case_type, + procedure: case.procedure, + judgment_date: case.judgment_date, + public_date: case.public_date, + parties: case.parties, + cause: case.cause, + legal_basis: case.legal_basis, + full_text: case.full_text, + }; + cases.push(case_data); } } - // export to csv + let elapsed = now.elapsed().as_secs_f32(); if export { - let fname = format!("{search}_{total}_{limit}_{offset}.csv"); + info!( + "export {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s" + ); + } else { + info!( + "search {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s " + ); + } + + let search_meta = SearchMeta { + offset, + search, + search_type, + total, + export, + limit, + enable_vsearch: cfg!(feature = "vsearch"), + }; + + (cases, search_meta) +} + +pub async fn search(query: Query, state: State) -> impl IntoResponse { + let (cases, search_meta) = search_cases(query, state).await; + + // export to csv + if search_meta.export { + let fname = format!( + "{}_{}_{}_{}.csv", + search_meta.search, search_meta.total, search_meta.limit, search_meta.offset + ); let body = Vec::new(); let mut wtr = csv::Writer::from_writer(body); wtr.write_record([ @@ -275,9 +317,9 @@ pub async fn search( "full_text", ]) .unwrap(); - for (id, _, case) in &cases { + for case in &cases { wtr.write_record([ - &id.to_string(), + &case.id.to_string(), &case.doc_id, &case.case_id, &case.case_name, @@ -305,145 +347,38 @@ pub async fn search( return (headers, wtr.into_inner().unwrap()).into_response(); } - let body = SearchPage { - search, - search_type, - offset, - cases, - total, - enable_vsearch: cfg!(feature = "vsearch"), - }; - + let body = SearchPage { search_meta, cases }; into_response(&body) } -#[derive(Debug, Serialize)] -struct CaseDetail { - id: u32, - case: Case, +pub async fn api_search(query: Query, state: State) -> impl IntoResponse { + let (cases, search_meta) = search_cases(query, state).await; + let search_data = SearchData { search_meta, cases }; + Json(search_data).into_response() } -pub async fn api_search( - Query(input): Query, - State(state): State, -) -> impl IntoResponse { - let mut offset = input.offset.unwrap_or_default(); - if offset > *MAX_RESULTS { - offset = *MAX_RESULTS - } - let search = input.search.unwrap_or_default(); - let search_type = - if cfg!(feature = "vsearch") && input.search_type.as_deref() == Some("vsearch") { - "vsearch".to_owned() - } else { - "keyword".to_owned() - }; - let limit = 20; - let mut ids: IndexSet = IndexSet::with_capacity(20); - let mut total = 0; - if !search.trim().is_empty() { - let now = std::time::Instant::now(); - let search = fast2s::convert(&search); - if search_type == "keyword" { - let (query, _) = state.searcher.query_parser.parse_query_lenient(&search); - let searcher = state.searcher.reader.searcher(); - total = searcher.search(&query, &Count).unwrap(); - - let top_docs: Vec<(Score, DocAddress)> = searcher - .search( - &query, - &TopDocs::with_limit(limit) - .and_offset(offset) - .order_by_score(), - ) - .unwrap_or_default(); - - for (_score, doc_address) in top_docs { - if let Some(id) = searcher - .doc::(doc_address) - .unwrap() - .get_first(state.searcher.id) - .unwrap() - .as_u64() - { - ids.insert(id as u32); - } - } - } else { - #[cfg(feature = "vsearch")] - if search_type == "vsearch" { - { - let query_vec = MODEL.lock().unwrap().embed(vec![&search], None).unwrap(); - let client = state.qclient; - let search_limit = limit + offset; - total = search_limit; - if let Ok(search_result) = client - .search_points( - SearchPointsBuilder::new( - &CONFIG.collection_name, - query_vec.into_iter().next().unwrap(), - search_limit as u64, - ) - .with_payload(false) - .limit(limit as u64) - .offset(offset as u64), - ) - .await - { - for point in &search_result.result { - let id = point - .id - .as_ref() - .unwrap() - .point_id_options - .as_ref() - .unwrap(); - if let PointIdOptions::Num(id) = id { - ids.insert(*id as u32); - } - } - } else { - tracing::error!("Qdrant search_points failed"); - } - } - } - } - - let elapsed = now.elapsed().as_secs_f32(); - - info!( - "api search {search_type} {search}, total:{total}, offset: {offset}, limit: {limit}, elapsed: {elapsed}s " - ); - } - - let mut cases = Vec::with_capacity(ids.len()); - for id in ids { - if let Some(v) = state.db.get(id.to_be_bytes()).unwrap() { - let (case, _): (Case, _) = bincode::decode_from_slice(&v, standard()).unwrap(); - let case_detail = CaseDetail { id, case }; - cases.push(case_detail); - } - } - let search_data = SearchData { - search, - search_type, - offset, - cases, - total, - enable_vsearch: cfg!(feature = "vsearch"), - }; - - Json(search_data).into_response() +#[derive(Serialize)] +pub struct CaseData { + id: u32, + preview: String, + doc_id: String, + case_id: String, + case_name: String, + court: String, + case_type: String, + procedure: String, + judgment_date: String, + public_date: String, + parties: String, + cause: String, + legal_basis: String, + full_text: String, } #[derive(Serialize)] pub struct SearchData { - search: String, - offset: usize, - total: usize, - search_type: String, - enable_vsearch: bool, - cases: Vec, + search_meta: SearchMeta, + cases: Vec, } pub async fn style() -> impl IntoResponse { diff --git a/templates/search.html b/templates/search.html index c915778..0d0dcc4 100644 --- a/templates/search.html +++ b/templates/search.html @@ -20,12 +20,12 @@ type="text" id="search" name="search" - value="{{ search }}" + value="{{ search_meta.search }}" /> - {% if enable_vsearch %} + {% if search_meta.enable_vsearch %} {% endif %} @@ -44,47 +44,47 @@

- {% if search_type =="keyword" %} 找到 {{ total }},{% endif %} + {% if search_meta.search_type =="keyword" %} 找到 {{ search_meta.total }},{% endif %} 导出

- {% if enable_vsearch %} - {% if search_type == "keyword" %} - {% if total < 30 %} - {% if !search.is_empty() %} -

当前结果较少,建议使用 语义搜索

+ {% if search_meta.enable_vsearch %} + {% if search_meta.search_type == "keyword" %} + {% if search_meta.total < 30 %} + {% if !search_meta.search.is_empty() %} +

当前结果较少,建议使用 语义搜索

{% endif %} {% endif %} {% endif %} {% endif %}
- {% for (id, preview, case) in cases %} + {% for case in cases %}
- +

{{ case.case_name }}

{{ case.judgment_date }} - {{ case.case_type }} - {{ case.procedure }}

-

{{ preview }}

+

{{ case.preview }}

{{ case.case_id}} - {{ case.court }}

{% endfor %}