feat: add reviewed OCR and AI document workflow
This commit is contained in:
Generated
+298
-2
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(¤t) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user