feat: add reviewed OCR and AI document workflow

This commit is contained in:
leefer
2026-08-01 15:59:28 +08:00
parent 3ef13b5674
commit a3203f95da
12 changed files with 1808 additions and 6 deletions
+298 -2
View File
@@ -321,6 +321,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -402,6 +419,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -859,6 +885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1039,8 +1066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1062,8 +1091,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"wasm-bindgen",
]
[[package]]
@@ -1331,6 +1363,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1743,6 +1791,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2363,6 +2417,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2384,6 +2494,32 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2459,6 +2595,46 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2493,6 +2669,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -2522,6 +2712,41 @@ dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -2724,6 +2949,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -2794,7 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -2922,6 +3159,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3085,7 +3328,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3386,6 +3629,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3676,6 +3929,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -3869,6 +4128,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -3925,6 +4194,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4155,6 +4433,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4427,14 +4714,17 @@ dependencies = [
name = "xiaobai-bookkeeping"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"csv",
"getrandom 0.2.17",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"toml 0.8.2",
"windows-sys 0.59.0",
]
@@ -4502,6 +4792,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
+3
View File
@@ -13,13 +13,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
base64 = "0.22"
csv = "1.3"
getrandom = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["backup", "bundled-sqlcipher-vendored-openssl"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tauri = { version = "2", features = [] }
toml = "0.8"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
+671
View File
@@ -0,0 +1,671 @@
use crate::providers::{self, ProviderFailure};
use crate::{open_database, parse_amount_to_cents, sha256_hex};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use tauri::AppHandle;
const MAX_IMAGE_BYTES: usize = 2_500_000;
const EXTRACTION_SCHEMA_VERSION: &str = "invoice-extraction-v1";
const PROMPT_VERSION: &str = "bookkeeping-suggestion-v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InvoiceLineItem {
name: String,
amount_in_cents: Option<i64>,
tax_rate: String,
tax_in_cents: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct InvoiceExtraction {
invoice_type: String,
invoice_number: String,
invoice_date: String,
purchaser_name: String,
purchaser_tax_id: String,
seller_name: String,
seller_tax_id: String,
total_amount_in_cents: Option<i64>,
total_tax_in_cents: Option<i64>,
total_with_tax_in_cents: Option<i64>,
amount_in_words: String,
drawer: String,
remarks: String,
confidence: f64,
line_items: Vec<InvoiceLineItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BookkeepingSuggestion {
direction: String,
category: String,
counterparty: String,
occurred_on: String,
amount_in_cents: i64,
business_purpose: String,
confidence: f64,
reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StoredDocument {
id: String,
file_name: String,
mime_type: String,
size_bytes: i64,
status: String,
last_error: Option<String>,
created_at: String,
updated_at: String,
extraction: Option<InvoiceExtraction>,
suggestion: Option<BookkeepingSuggestion>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RecognizeDocumentInput {
file_name: String,
mime_type: String,
bytes: Vec<u8>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfirmDocumentInput {
document_id: String,
invoice_number: String,
invoice_date: String,
purchaser_name: String,
purchaser_tax_id: String,
seller_name: String,
seller_tax_id: String,
total_amount_in_cents: Option<i64>,
total_tax_in_cents: Option<i64>,
total_with_tax_in_cents: Option<i64>,
}
fn one_word(result: &Value, name: &str) -> String {
result
.get(name)
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("word"))
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string()
}
fn row_words(result: &Value, name: &str) -> BTreeMap<String, String> {
result
.get(name)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let word = item.get("word")?.as_str()?.trim();
if word.is_empty() {
return None;
}
let row = item
.get("row")
.and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_i64().map(|number| number.to_string())))
.unwrap_or_else(|| (index + 1).to_string());
Some((row, word.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn optional_cents(value: &str) -> Option<i64> {
if value.trim().is_empty() {
None
} else {
parse_amount_to_cents(value).ok()
}
}
fn normalized_date(value: &str) -> String {
let normalized = value
.trim()
.replace('年', "-")
.replace('月', "-")
.replace('日', "")
.replace('/', "-")
.replace('.', "-");
let parts: Vec<_> = normalized.split('-').collect();
if parts.len() == 3 {
if let (Ok(year), Ok(month), Ok(day)) = (
parts[0].parse::<u16>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
) {
if (2000..=2100).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) {
return format!("{year:04}-{month:02}-{day:02}");
}
}
}
value.trim().to_string()
}
fn build_line_items(result: &Value) -> Vec<InvoiceLineItem> {
let names = row_words(result, "CommodityName");
let amounts = row_words(result, "CommodityAmount");
let rates = row_words(result, "CommodityTaxRate");
let taxes = row_words(result, "CommodityTax");
let mut rows = BTreeMap::<String, InvoiceLineItem>::new();
for row in names.keys().chain(amounts.keys()).chain(rates.keys()).chain(taxes.keys()) {
rows.entry(row.clone()).or_insert_with(|| InvoiceLineItem {
name: String::new(),
amount_in_cents: None,
tax_rate: String::new(),
tax_in_cents: None,
});
}
for (row, item) in &mut rows {
item.name = names.get(row).cloned().unwrap_or_default();
item.amount_in_cents = amounts.get(row).and_then(|value| optional_cents(value));
item.tax_rate = rates.get(row).cloned().unwrap_or_default();
item.tax_in_cents = taxes.get(row).and_then(|value| optional_cents(value));
}
rows.into_values().collect()
}
fn parse_baidu_invoice(response: &Value) -> Result<InvoiceExtraction, String> {
let invoice = response
.get("words_result")
.and_then(Value::as_array)
.and_then(|items| items.first())
.ok_or_else(|| "没有识别到可用票据".to_string())?;
let invoice_type_code = invoice.get("type").and_then(Value::as_str).unwrap_or_default();
if invoice_type_code != "vat_invoice" {
return Err(format!("当前版本只开放增值税发票字段核对,识别类型为 {invoice_type_code}"));
}
let result = invoice.get("result").ok_or_else(|| "票据结果缺少结构化字段".to_string())?;
let type_name = one_word(result, "InvoiceType");
let title = one_word(result, "InvoiceTypeOrg");
Ok(InvoiceExtraction {
invoice_type: if type_name.is_empty() { title } else { type_name },
invoice_number: one_word(result, "InvoiceNum"),
invoice_date: normalized_date(&one_word(result, "InvoiceDate")),
purchaser_name: one_word(result, "PurchaserName"),
purchaser_tax_id: one_word(result, "PurchaserRegisterNum"),
seller_name: one_word(result, "SellerName"),
seller_tax_id: one_word(result, "SellerRegisterNum"),
total_amount_in_cents: optional_cents(&one_word(result, "TotalAmount")),
total_tax_in_cents: optional_cents(&one_word(result, "TotalTax")),
total_with_tax_in_cents: optional_cents(&one_word(result, "AmountInFiguers")),
amount_in_words: one_word(result, "AmountInWords"),
drawer: one_word(result, "NoteDrawer"),
remarks: one_word(result, "Remarks"),
confidence: invoice.get("probability").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
line_items: build_line_items(result),
})
}
fn valid_image_signature(mime_type: &str, bytes: &[u8]) -> bool {
match mime_type {
"image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
"image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]),
"image/bmp" => bytes.starts_with(b"BM"),
"image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP",
_ => false,
}
}
fn validate_upload(input: &RecognizeDocumentInput) -> Result<(), String> {
let name_length = input.file_name.trim().chars().count();
if !(1..=180).contains(&name_length) {
return Err("文件名长度不正确".to_string());
}
if input.bytes.is_empty() || input.bytes.len() > MAX_IMAGE_BYTES {
return Err("图片应小于 2.5 MB".to_string());
}
if !valid_image_signature(&input.mime_type, &input.bytes) {
return Err("图片格式与文件内容不一致,当前支持 PNG、JPG、BMP 和 WebP".to_string());
}
Ok(())
}
fn parse_json<T: for<'de> Deserialize<'de>>(value: Option<String>) -> Option<T> {
value.and_then(|text| serde_json::from_str(&text).ok())
}
fn document_from_connection(connection: &Connection, id: &str) -> Result<StoredDocument, String> {
connection
.query_row(
"
SELECT d.id, d.file_name, d.mime_type, d.size_bytes, d.status, d.last_error,
d.created_at, d.updated_at,
(SELECT reviewed_json FROM document_reviews WHERE document_id = d.id ORDER BY id DESC LIMIT 1),
(SELECT normalized_json FROM document_extractions WHERE document_id = d.id ORDER BY id DESC LIMIT 1),
(SELECT normalized_json FROM ai_runs WHERE document_id = d.id AND status = 'succeeded' ORDER BY id DESC LIMIT 1)
FROM documents d WHERE d.id = ?1
",
[id],
|row| {
let reviewed: Option<String> = row.get(8)?;
let extracted: Option<String> = row.get(9)?;
let suggestion: Option<String> = row.get(10)?;
Ok(StoredDocument {
id: row.get(0)?,
file_name: row.get(1)?,
mime_type: row.get(2)?,
size_bytes: row.get(3)?,
status: row.get(4)?,
last_error: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
extraction: parse_json(reviewed.or(extracted)),
suggestion: parse_json(suggestion),
})
},
)
.map_err(|error| format!("读取票据失败:{error}"))
}
#[tauri::command]
pub(crate) fn list_documents(app: AppHandle) -> Result<Vec<StoredDocument>, String> {
let connection = open_database(&app)?;
let mut statement = connection
.prepare("SELECT id FROM documents ORDER BY created_at DESC, id DESC")
.map_err(|error| format!("读取票据列表失败:{error}"))?;
let ids = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| format!("读取票据列表失败:{error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取票据列表失败:{error}"))?;
ids.iter().map(|id| document_from_connection(&connection, id)).collect()
}
fn mark_ocr_failure(app: &AppHandle, document_id: &str, run_id: i64, failure: &ProviderFailure) {
if let Ok(connection) = open_database(app) {
let _ = connection.execute(
"UPDATE ocr_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
params![failure.code, run_id],
);
let _ = connection.execute(
"UPDATE documents SET status = 'failed', last_error = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
params![failure.message, document_id],
);
}
}
#[tauri::command]
pub(crate) async fn recognize_document(
app: AppHandle,
input: RecognizeDocumentInput,
) -> Result<StoredDocument, String> {
validate_upload(&input)?;
let content_hash = sha256_hex(&input.bytes);
let document_id = format!("DOC-{}", &content_hash[..20]);
let connection = open_database(&app)?;
connection
.execute(
"
INSERT OR IGNORE INTO documents(
id, content_hash, file_name, mime_type, size_bytes, encrypted_content, status
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'stored')
",
params![&document_id, &content_hash, input.file_name.trim(), &input.mime_type, input.bytes.len() as i64, &input.bytes],
)
.map_err(|error| format!("保存加密票据失败:{error}"))?;
let existing = document_from_connection(&connection, &document_id)?;
if existing.extraction.is_some() && matches!(existing.status.as_str(), "needs_review" | "confirmed") {
return Ok(existing);
}
connection
.execute(
"UPDATE documents SET status = 'processing', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
[&document_id],
)
.map_err(|error| format!("更新票据状态失败:{error}"))?;
connection
.execute(
"INSERT INTO ocr_runs(document_id, provider, operation, request_version, status) VALUES (?1, 'baidu', 'multiple_invoice', '2026-06', 'running')",
[&document_id],
)
.map_err(|error| format!("创建 OCR 审计记录失败:{error}"))?;
let run_id = connection.last_insert_rowid();
drop(connection);
let config = providers::load_config()?;
let mime_type = input.mime_type;
let bytes = input.bytes;
let task = tauri::async_runtime::spawn_blocking(move || {
providers::recognize_financial_document(&config, &mime_type, &bytes)
});
let response = match task.await {
Ok(Ok(response)) => response,
Ok(Err(failure)) => {
mark_ocr_failure(&app, &document_id, run_id, &failure);
return Err(failure.message);
}
Err(_) => {
let failure = ProviderFailure { code: "ocr_worker".to_string(), message: "OCR 任务意外中止,请重试".to_string() };
mark_ocr_failure(&app, &document_id, run_id, &failure);
return Err(failure.message);
}
};
let extraction = match parse_baidu_invoice(&response.raw) {
Ok(value) => value,
Err(message) => {
let failure = ProviderFailure { code: "unsupported_extraction".to_string(), message };
mark_ocr_failure(&app, &document_id, run_id, &failure);
return Err(failure.message);
}
};
let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 OCR 原始响应".to_string())?;
let normalized_json = serde_json::to_string(&extraction).map_err(|_| "无法保存 OCR 结构化结果".to_string())?;
let mut connection = open_database(&app)?;
let transaction = connection.transaction().map_err(|error| format!("无法提交 OCR 结果:{error}"))?;
transaction.execute(
"UPDATE ocr_runs SET status = 'succeeded', raw_response_json = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
params![raw_json, run_id],
).map_err(|error| format!("保存 OCR 审计记录失败:{error}"))?;
transaction.execute(
"
INSERT INTO document_extractions(
document_id, ocr_run_id, schema_version, invoice_type, invoice_number, invoice_date,
purchaser_name, purchaser_tax_id, seller_name, seller_tax_id, total_amount_in_cents,
total_tax_in_cents, total_with_tax_in_cents, confidence, normalized_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
",
params![
document_id, run_id, EXTRACTION_SCHEMA_VERSION, extraction.invoice_type,
extraction.invoice_number, extraction.invoice_date, extraction.purchaser_name,
extraction.purchaser_tax_id, extraction.seller_name, extraction.seller_tax_id,
extraction.total_amount_in_cents, extraction.total_tax_in_cents,
extraction.total_with_tax_in_cents, extraction.confidence, normalized_json,
],
).map_err(|error| format!("保存 OCR 结构化结果失败:{error}"))?;
transaction.execute(
"UPDATE documents SET status = 'needs_review', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
[&document_id],
).map_err(|error| format!("更新票据状态失败:{error}"))?;
transaction.commit().map_err(|error| format!("提交 OCR 结果失败:{error}"))?;
document_from_connection(&connection, &document_id)
}
fn valid_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 10
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes
.iter()
.enumerate()
.any(|(index, value)| index != 4 && index != 7 && !value.is_ascii_digit())
{
return false;
}
let year = value[0..4].parse::<u16>().ok();
let month = value[5..7].parse::<u8>().ok();
let day = value[8..10].parse::<u8>().ok();
matches!((year, month, day), (Some(2000..=2100), Some(1..=12), Some(1..=31)))
}
fn validate_suggestion(value: Value, extraction: &InvoiceExtraction) -> Result<BookkeepingSuggestion, String> {
let suggestion: BookkeepingSuggestion = serde_json::from_value(value)
.map_err(|_| "LLM 建议缺少必要字段或字段类型错误".to_string())?;
let categories = [
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅",
"业务招待", "平台服务费", "税费支出", "其他经营支出",
];
if !matches!(suggestion.direction.as_str(), "income" | "expense") {
return Err("LLM 建议的收支方向无效".to_string());
}
if !categories.contains(&suggestion.category.as_str()) {
return Err("LLM 建议的分类不在允许范围内".to_string());
}
if !valid_iso_date(&suggestion.occurred_on) {
return Err("LLM 建议的业务日期格式无效".to_string());
}
if extraction.total_with_tax_in_cents != Some(suggestion.amount_in_cents) {
return Err("LLM 建议金额与票据价税合计不一致".to_string());
}
if suggestion.counterparty.trim().is_empty() || suggestion.counterparty.chars().count() > 100 {
return Err("LLM 建议的交易对方无效".to_string());
}
if suggestion.business_purpose.chars().count() > 40 || suggestion.reason.chars().count() > 80 {
return Err("LLM 建议的说明过长".to_string());
}
if !(0.0..=1.0).contains(&suggestion.confidence) {
return Err("LLM 建议置信度无效".to_string());
}
Ok(suggestion)
}
fn suggestion_input(extraction: &InvoiceExtraction) -> Value {
json!({
"invoiceType": extraction.invoice_type,
"invoiceDate": extraction.invoice_date,
"purchaserName": extraction.purchaser_name,
"sellerName": extraction.seller_name,
"totalWithTaxInCents": extraction.total_with_tax_in_cents,
"items": extraction.line_items.iter().map(|item| &item.name).collect::<Vec<_>>()
})
}
fn mark_ai_failure(app: &AppHandle, run_id: i64, failure: &ProviderFailure) {
if let Ok(connection) = open_database(app) {
let _ = connection.execute(
"UPDATE ai_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
params![failure.code, run_id],
);
}
}
fn mark_ai_validation_failure(app: &AppHandle, run_id: i64, raw: &Value, failure: &ProviderFailure) {
if let Ok(connection) = open_database(app) {
let raw_json = serde_json::to_string(raw).ok();
let _ = connection.execute(
"UPDATE ai_runs SET status = 'failed', response_json = ?1, error_code = ?2, completed_at = CURRENT_TIMESTAMP WHERE id = ?3",
params![raw_json, failure.code, run_id],
);
}
}
#[tauri::command]
pub(crate) async fn generate_document_suggestion(
app: AppHandle,
document_id: String,
) -> Result<StoredDocument, String> {
let connection = open_database(&app)?;
let extraction_json: Option<String> = connection.query_row(
"SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1",
[&document_id],
|row| row.get(0),
).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?;
let extraction: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能生成记账建议".to_string())?;
let input = suggestion_input(&extraction);
let input_json = serde_json::to_string(&input).map_err(|_| "无法准备 LLM 输入".to_string())?;
let input_hash = sha256_hex(input_json.as_bytes());
let config = providers::load_config()?;
connection.execute(
"INSERT INTO ai_runs(document_id, provider, model, prompt_version, input_hash, status) VALUES (?1, 'openai_compatible', ?2, ?3, ?4, 'running')",
params![document_id, config.llm.model, PROMPT_VERSION, input_hash],
).map_err(|error| format!("创建 LLM 审计记录失败:{error}"))?;
let run_id = connection.last_insert_rowid();
drop(connection);
let task = tauri::async_runtime::spawn_blocking(move || providers::request_bookkeeping_suggestion(&config, &input));
let response = match task.await {
Ok(Ok(response)) => response,
Ok(Err(failure)) => {
mark_ai_failure(&app, run_id, &failure);
return Err(failure.message);
}
Err(_) => {
let failure = ProviderFailure { code: "llm_worker".to_string(), message: "LLM 任务意外中止,请重试".to_string() };
mark_ai_failure(&app, run_id, &failure);
return Err(failure.message);
}
};
let suggestion = match validate_suggestion(response.content.clone(), &extraction) {
Ok(value) => value,
Err(message) => {
let failure = ProviderFailure { code: "llm_validation".to_string(), message };
mark_ai_validation_failure(&app, run_id, &response.raw, &failure);
return Err(failure.message);
}
};
let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 LLM 原始响应".to_string())?;
let normalized_json = serde_json::to_string(&suggestion).map_err(|_| "无法保存记账建议".to_string())?;
let connection = open_database(&app)?;
connection.execute(
"UPDATE ai_runs SET status = 'succeeded', model = ?1, response_json = ?2, normalized_json = ?3, completed_at = CURRENT_TIMESTAMP WHERE id = ?4",
params![response.model, raw_json, normalized_json, run_id],
).map_err(|error| format!("保存 LLM 建议失败:{error}"))?;
document_from_connection(&connection, &document_id)
}
fn validate_review(input: &ConfirmDocumentInput, original: &InvoiceExtraction) -> Result<InvoiceExtraction, String> {
let invoice_number = input.invoice_number.trim().to_string();
let invoice_date = input.invoice_date.trim().to_string();
let seller_name = input.seller_name.trim().to_string();
if invoice_number.is_empty() || invoice_number.chars().count() > 40 {
return Err("请核对并填写有效的发票号码".to_string());
}
if !valid_iso_date(&invoice_date) {
return Err("开票日期应为 YYYY-MM-DD".to_string());
}
if seller_name.is_empty() || seller_name.chars().count() > 100 {
return Err("请核对并填写销售方名称".to_string());
}
for amount in [input.total_amount_in_cents, input.total_tax_in_cents, input.total_with_tax_in_cents].into_iter().flatten() {
if amount < 0 {
return Err("票据合计金额不能为负数".to_string());
}
}
if let (Some(amount), Some(tax), Some(total)) = (
input.total_amount_in_cents,
input.total_tax_in_cents,
input.total_with_tax_in_cents,
) {
if (amount + tax - total).abs() > 1 {
return Err("不含税金额与税额之和不等于价税合计".to_string());
}
}
if input.total_with_tax_in_cents.is_none() {
return Err("请核对价税合计".to_string());
}
let mut reviewed = original.clone();
reviewed.invoice_number = invoice_number;
reviewed.invoice_date = invoice_date;
reviewed.purchaser_name = input.purchaser_name.trim().to_string();
reviewed.purchaser_tax_id = input.purchaser_tax_id.trim().to_string();
reviewed.seller_name = seller_name;
reviewed.seller_tax_id = input.seller_tax_id.trim().to_string();
reviewed.total_amount_in_cents = input.total_amount_in_cents;
reviewed.total_tax_in_cents = input.total_tax_in_cents;
reviewed.total_with_tax_in_cents = input.total_with_tax_in_cents;
reviewed.confidence = 1.0;
Ok(reviewed)
}
#[tauri::command]
pub(crate) fn confirm_document(
app: AppHandle,
input: ConfirmDocumentInput,
) -> Result<StoredDocument, String> {
let mut connection = open_database(&app)?;
let extraction_json: Option<String> = connection.query_row(
"SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1",
[&input.document_id],
|row| row.get(0),
).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?;
let original: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能确认".to_string())?;
let reviewed = validate_review(&input, &original)?;
let reviewed_json = serde_json::to_string(&reviewed).map_err(|_| "无法保存人工核对结果".to_string())?;
let transaction = connection.transaction().map_err(|error| format!("无法开始票据确认:{error}"))?;
transaction.execute(
"INSERT INTO document_reviews(document_id, reviewed_json, actor) VALUES (?1, ?2, 'user')",
params![input.document_id, reviewed_json],
).map_err(|error| format!("保存人工核对记录失败:{error}"))?;
transaction.execute(
"UPDATE documents SET status = 'confirmed', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
[&input.document_id],
).map_err(|error| format!("更新票据状态失败:{error}"))?;
transaction.commit().map_err(|error| format!("提交票据确认失败:{error}"))?;
document_from_connection(&connection, &input.document_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_baidu_vat_invoice_into_integer_cents() {
let value = json!({
"words_result_num": 1,
"words_result": [{
"type": "vat_invoice",
"probability": 0.959,
"result": {
"InvoiceType": [{"word": "电子发票(普通发票)"}],
"InvoiceNum": [{"word": "26427000000291271373"}],
"InvoiceDate": [{"word": "2026年03月16日"}],
"SellerName": [{"word": "武汉测试贸易有限公司"}],
"TotalAmount": [{"word": "56.48"}],
"TotalTax": [{"word": "7.33"}],
"AmountInFiguers": [{"word": "63.81"}],
"CommodityName": [{"row": "1", "word": "清洁用品"}],
"CommodityAmount": [{"row": "1", "word": "56.48"}],
"CommodityTaxRate": [{"row": "1", "word": "13%"}],
"CommodityTax": [{"row": "1", "word": "7.33"}]
}
}]
});
let parsed = parse_baidu_invoice(&value).unwrap();
assert_eq!(parsed.invoice_date, "2026-03-16");
assert_eq!(parsed.total_with_tax_in_cents, Some(6_381));
assert_eq!(parsed.line_items[0].tax_in_cents, Some(733));
}
#[test]
fn rejects_llm_amounts_that_differ_from_invoice() {
let extraction = InvoiceExtraction {
invoice_type: "电子普通发票".to_string(),
invoice_number: "1".to_string(),
invoice_date: "2026-03-16".to_string(),
purchaser_name: String::new(),
purchaser_tax_id: String::new(),
seller_name: "测试商户".to_string(),
seller_tax_id: String::new(),
total_amount_in_cents: Some(5_648),
total_tax_in_cents: Some(733),
total_with_tax_in_cents: Some(6_381),
amount_in_words: String::new(),
drawer: String::new(),
remarks: String::new(),
confidence: 0.95,
line_items: vec![],
};
let candidate = json!({
"direction": "expense",
"category": "进货成本",
"counterparty": "测试商户",
"occurredOn": "2026-03-16",
"amountInCents": 6380,
"businessPurpose": "购买耗材",
"confidence": 0.8,
"reason": "票据内容显示为经营耗材"
});
assert!(validate_suggestion(candidate, &extraction).is_err());
}
#[test]
fn validates_file_signature_instead_of_only_trusting_mime_type() {
assert!(valid_image_signature("image/png", b"\x89PNG\r\n\x1a\nrest"));
assert!(!valid_image_signature("image/png", b"not an image"));
}
}
+98 -2
View File
@@ -1,3 +1,6 @@
mod documents;
mod providers;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -329,6 +332,94 @@ fn migrate(connection: &Connection) -> Result<(), String> {
ON transaction_corrections(transaction_id, id DESC);
INSERT OR IGNORE INTO schema_migrations(version) VALUES (4);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL CHECK (size_bytes > 0),
encrypted_content BLOB NOT NULL,
status TEXT NOT NULL CHECK (status IN ('stored', 'processing', 'needs_review', 'confirmed', 'failed')),
last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ocr_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
provider TEXT NOT NULL,
operation TEXT NOT NULL,
request_version TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
raw_response_json TEXT,
error_code TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE INDEX IF NOT EXISTS idx_ocr_runs_document
ON ocr_runs(document_id, id DESC);
CREATE TABLE IF NOT EXISTS document_extractions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
ocr_run_id INTEGER NOT NULL,
schema_version TEXT NOT NULL,
invoice_type TEXT NOT NULL,
invoice_number TEXT NOT NULL,
invoice_date TEXT NOT NULL,
purchaser_name TEXT NOT NULL,
purchaser_tax_id TEXT NOT NULL,
seller_name TEXT NOT NULL,
seller_tax_id TEXT NOT NULL,
total_amount_in_cents INTEGER,
total_tax_in_cents INTEGER,
total_with_tax_in_cents INTEGER,
confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
normalized_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id),
FOREIGN KEY (ocr_run_id) REFERENCES ocr_runs(id)
);
CREATE INDEX IF NOT EXISTS idx_document_extractions_latest
ON document_extractions(document_id, id DESC);
CREATE TABLE IF NOT EXISTS ai_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_version TEXT NOT NULL,
input_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
response_json TEXT,
normalized_json TEXT,
error_code TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE INDEX IF NOT EXISTS idx_ai_runs_document
ON ai_runs(document_id, id DESC);
CREATE TABLE IF NOT EXISTS document_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
reviewed_json TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE INDEX IF NOT EXISTS idx_document_reviews_latest
ON document_reviews(document_id, id DESC);
INSERT OR IGNORE INTO schema_migrations(version) VALUES (5);
",
)
.map_err(|error| format!("本地账本升级失败:{error}"))?;
@@ -1296,7 +1387,12 @@ pub fn run() {
restore_local_backup,
create_manual_transaction,
correct_transaction,
export_ledger_csv
export_ledger_csv,
providers::get_provider_status,
documents::list_documents,
documents::recognize_document,
documents::generate_document_suggestion,
documents::confirm_document
])
.run(tauri::generate_context!())
.expect("failed to start Xiaobai Bookkeeping");
@@ -1564,7 +1660,7 @@ mod tests {
let migration_count: i64 = connection
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
.unwrap();
assert_eq!(migration_count, 4);
assert_eq!(migration_count, 5);
let listed = list_backups_in_directory(&directory, None).unwrap();
assert!(!listed[0].is_valid);
let _ = fs::remove_dir_all(directory);
+333
View File
@@ -0,0 +1,333 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
const BAIDU_TOKEN_URL: &str = "https://aip.baidubce.com/oauth/2.0/token";
const BAIDU_MULTIPLE_INVOICE_URL: &str =
"https://aip.baidubce.com/rest/2.0/ocr/v1/multiple_invoice";
#[derive(Deserialize)]
pub(crate) struct ProviderConfig {
pub(crate) baidu_ocr: BaiduOcrConfig,
pub(crate) llm: LlmConfig,
}
#[derive(Deserialize)]
pub(crate) struct BaiduOcrConfig {
pub(crate) enabled: bool,
pub(crate) auth_mode: String,
pub(crate) api_key: String,
pub(crate) secret_key: String,
pub(crate) access_token: String,
pub(crate) service: String,
pub(crate) request_timeout_seconds: u64,
}
#[derive(Deserialize)]
pub(crate) struct LlmConfig {
pub(crate) enabled: bool,
pub(crate) base_url: String,
pub(crate) api_key: String,
pub(crate) model: String,
pub(crate) protocol: String,
pub(crate) request_timeout_seconds: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderStatus {
ocr_configured: bool,
ocr_enabled: bool,
ocr_provider: &'static str,
llm_configured: bool,
llm_enabled: bool,
llm_provider: &'static str,
llm_model: String,
local_development_only: bool,
}
#[derive(Debug)]
pub(crate) struct ProviderFailure {
pub(crate) code: String,
pub(crate) message: String,
}
impl ProviderFailure {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self { code: code.into(), message: message.into() }
}
}
pub(crate) struct OcrResponse {
pub(crate) raw: Value,
}
pub(crate) struct LlmResponse {
pub(crate) raw: Value,
pub(crate) content: Value,
pub(crate) model: String,
}
fn find_config_from(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.map(|directory| directory.join("config").join("local.providers.toml"))
.find(|candidate| candidate.is_file())
}
fn config_path() -> Result<PathBuf, String> {
if let Some(path) = env::var_os("XIAOBAI_PROVIDER_CONFIG") {
let path = PathBuf::from(path);
if path.is_file() {
return Ok(path);
}
return Err("XIAOBAI_PROVIDER_CONFIG 指向的配置文件不存在".to_string());
}
if let Ok(current) = env::current_dir() {
if let Some(path) = find_config_from(&current) {
return Ok(path);
}
}
if let Ok(executable) = env::current_exe() {
if let Some(parent) = executable.parent() {
if let Some(path) = find_config_from(parent) {
return Ok(path);
}
}
}
Err("找不到本地服务配置 config/local.providers.toml".to_string())
}
pub(crate) fn load_config() -> Result<ProviderConfig, String> {
let path = config_path()?;
let text = fs::read_to_string(path).map_err(|_| "无法读取本地服务配置".to_string())?;
toml::from_str(&text).map_err(|error| format!("本地服务配置格式不正确:{error}"))
}
fn configured_ocr(config: &BaiduOcrConfig) -> bool {
match config.auth_mode.as_str() {
"api_key_secret" => !config.api_key.trim().is_empty() && !config.secret_key.trim().is_empty(),
"access_token" => !config.access_token.trim().is_empty(),
_ => false,
}
}
fn configured_llm(config: &LlmConfig) -> bool {
!config.base_url.trim().is_empty()
&& !config.api_key.trim().is_empty()
&& !config.model.trim().is_empty()
&& config.protocol == "openai_compatible"
}
#[tauri::command]
pub(crate) fn get_provider_status() -> Result<ProviderStatus, String> {
let config = load_config()?;
Ok(ProviderStatus {
ocr_configured: configured_ocr(&config.baidu_ocr),
ocr_enabled: config.baidu_ocr.enabled,
ocr_provider: "百度智能财务票据识别",
llm_configured: configured_llm(&config.llm),
llm_enabled: config.llm.enabled,
llm_provider: "兼容 LLM 服务",
llm_model: config.llm.model,
local_development_only: true,
})
}
fn timeout(seconds: u64) -> Duration {
Duration::from_secs(seconds.clamp(5, 120))
}
fn client(seconds: u64) -> Result<Client, ProviderFailure> {
Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(timeout(seconds))
.user_agent("XiaobaiBookkeeping-LocalDevelopment/0.10")
.build()
.map_err(|_| ProviderFailure::new("client_init", "无法初始化安全网络连接"))
}
fn baidu_access_token(config: &BaiduOcrConfig) -> Result<String, ProviderFailure> {
if config.auth_mode == "access_token" {
if config.access_token.trim().is_empty() {
return Err(ProviderFailure::new("missing_token", "百度 OCR access token 未填写"));
}
return Ok(config.access_token.trim().to_string());
}
if config.auth_mode != "api_key_secret" {
return Err(ProviderFailure::new("unsupported_auth", "百度 OCR 认证方式不受支持"));
}
if config.api_key.trim().is_empty() || config.secret_key.trim().is_empty() {
return Err(ProviderFailure::new("missing_credentials", "百度 OCR API Key 或 Secret Key 未填写"));
}
let response = client(config.request_timeout_seconds)?
.post(BAIDU_TOKEN_URL)
.form(&[
("grant_type", "client_credentials"),
("client_id", config.api_key.trim()),
("client_secret", config.secret_key.trim()),
])
.send()
.map_err(|_| ProviderFailure::new("baidu_auth_network", "无法连接百度 OCR 鉴权服务"))?;
let status = response.status();
let value: Value = response
.json()
.map_err(|_| ProviderFailure::new("baidu_auth_response", "百度 OCR 鉴权响应无法解析"))?;
if !status.is_success() {
return Err(ProviderFailure::new("baidu_auth_http", format!("百度 OCR 鉴权失败,HTTP {}", status.as_u16())));
}
value
.get("access_token")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
let code = value.get("error").and_then(Value::as_str).unwrap_or("missing_access_token");
ProviderFailure::new(code, "百度 OCR 凭据无效或无权获取 access token")
})
}
pub(crate) fn recognize_financial_document(
config: &ProviderConfig,
mime_type: &str,
bytes: &[u8],
) -> Result<OcrResponse, ProviderFailure> {
if !config.baidu_ocr.enabled {
return Err(ProviderFailure::new("ocr_disabled", "百度 OCR 已配置但尚未启用"));
}
if config.baidu_ocr.service != "mixed_invoice" {
return Err(ProviderFailure::new("unsupported_service", "当前只支持智能财务票据识别"));
}
if !matches!(mime_type, "image/png" | "image/jpeg" | "image/bmp" | "image/webp") {
return Err(ProviderFailure::new("unsupported_file", "当前只支持 PNG、JPG、BMP 或 WebP 图片"));
}
let token = baidu_access_token(&config.baidu_ocr)?;
let image = BASE64.encode(bytes);
let response = client(config.baidu_ocr.request_timeout_seconds)?
.post(BAIDU_MULTIPLE_INVOICE_URL)
.query(&[("access_token", token)])
.form(&[("image", image)])
.send()
.map_err(|_| ProviderFailure::new("baidu_ocr_network", "百度 OCR 请求失败,请检查网络后重试"))?;
let status = response.status();
let value: Value = response
.json()
.map_err(|_| ProviderFailure::new("baidu_ocr_response", "百度 OCR 响应无法解析"))?;
if !status.is_success() {
return Err(ProviderFailure::new("baidu_ocr_http", format!("百度 OCR 返回 HTTP {}", status.as_u16())));
}
if let Some(code) = value.get("error_code") {
return Err(ProviderFailure::new(
format!("baidu_{}", code.as_i64().unwrap_or_default()),
"百度 OCR 未能完成识别,请确认接口权限、额度和图片格式",
));
}
Ok(OcrResponse { raw: value })
}
fn chat_endpoint(base_url: &str) -> String {
let base = base_url.trim().trim_end_matches('/');
if base.ends_with("/chat/completions") {
base.to_string()
} else {
format!("{base}/chat/completions")
}
}
pub(crate) fn request_bookkeeping_suggestion(
config: &ProviderConfig,
input: &Value,
) -> Result<LlmResponse, ProviderFailure> {
if !config.llm.enabled {
return Err(ProviderFailure::new("llm_disabled", "LLM 已配置但尚未启用"));
}
if !configured_llm(&config.llm) {
return Err(ProviderFailure::new("llm_incomplete", "LLM 配置不完整或协议不受支持"));
}
let allowed_categories = [
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅",
"业务招待", "平台服务费", "税费支出", "其他经营支出",
];
let system = format!(
"你是小白记账的记账建议模块。只根据给定票据字段生成建议,不计算税务,不编造缺失事实。只能输出一个 JSON 对象,不要输出 Markdown、解释或思考过程。JSON 结构必须精确为:{{\"direction\":\"expense\",\"category\":\"进货成本\",\"counterparty\":\"交易对方\",\"occurredOn\":\"2026-01-01\",\"amountInCents\":100,\"businessPurpose\":\"经营用途\",\"confidence\":0.8,\"reason\":\"判断依据\"}}。category 只能是:{}。direction 只能是 income 或 expense。amountInCents 必须原样使用输入的价税合计分值。confidence 必须为 0 到 1。businessPurpose 不超过 40 个汉字,reason 不超过 80 个汉字。",
allowed_categories.join("")
);
let body = json!({
"model": config.llm.model,
"temperature": 0,
"max_tokens": 900,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string())}
]
});
let response = client(config.llm.request_timeout_seconds)?
.post(chat_endpoint(&config.llm.base_url))
.bearer_auth(config.llm.api_key.trim())
.json(&body)
.send()
.map_err(|_| ProviderFailure::new("llm_network", "无法连接 LLM 服务"))?;
let status = response.status();
let raw: Value = response
.json()
.map_err(|_| ProviderFailure::new("llm_response", "LLM 响应无法解析"))?;
if !status.is_success() {
return Err(ProviderFailure::new("llm_http", format!("LLM 服务返回 HTTP {}", status.as_u16())));
}
let text = raw
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.ok_or_else(|| ProviderFailure::new("llm_format", "LLM 没有返回兼容的消息内容"))?;
let cleaned = extract_json_object(text)
.ok_or_else(|| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?;
let content: Value = serde_json::from_str(cleaned)
.map_err(|_| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?;
Ok(LlmResponse { raw, content, model: config.llm.model.clone() })
}
fn extract_json_object(text: &str) -> Option<&str> {
let trimmed = text.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
return Some(trimmed);
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
(end > start).then_some(&trimmed[start..=end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chat_endpoint_accepts_base_and_complete_urls() {
assert_eq!(chat_endpoint("https://example.test/v1"), "https://example.test/v1/chat/completions");
assert_eq!(chat_endpoint("https://example.test/v1/chat/completions"), "https://example.test/v1/chat/completions");
}
#[test]
fn provider_configuration_checks_auth_modes_without_exposing_values() {
let config = BaiduOcrConfig {
enabled: false,
auth_mode: "api_key_secret".to_string(),
api_key: "key".to_string(),
secret_key: "secret".to_string(),
access_token: String::new(),
service: "mixed_invoice".to_string(),
request_timeout_seconds: 30,
};
assert!(configured_ocr(&config));
}
#[test]
fn extracts_json_from_fences_and_reasoning_prefixes() {
assert_eq!(extract_json_object("```json\n{\"ok\":true}\n```"), Some("{\"ok\":true}"));
assert_eq!(extract_json_object("<think>internal</think>\n{\"ok\":true}"), Some("{\"ok\":true}"));
assert_eq!(extract_json_object("没有对象"), None);
}
}