Implement semantic embeddings and duplicate review

This commit is contained in:
2026-07-19 18:06:47 +02:00
parent fd4fd744e0
commit e8764262a3
42 changed files with 3808 additions and 79 deletions

630
Cargo.lock generated
View File

@@ -68,7 +68,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"actix-utils", "actix-utils",
"base64", "base64 0.22.1",
"bitflags 2.11.0", "bitflags 2.11.0",
"brotli", "brotli",
"bytes", "bytes",
@@ -250,6 +250,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"serde",
"version_check", "version_check",
"zerocopy", "zerocopy",
] ]
@@ -777,6 +778,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -801,12 +808,13 @@ name = "bds-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64 0.22.1",
"chrono", "chrono",
"deunicode", "deunicode",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dirs 5.0.1", "dirs 5.0.1",
"fastembed",
"fluent-bundle", "fluent-bundle",
"fluent-syntax", "fluent-syntax",
"htmd", "htmd",
@@ -816,6 +824,7 @@ dependencies = [
"liquid", "liquid",
"liquid-core", "liquid-core",
"mlua", "mlua",
"ort",
"pagefind", "pagefind",
"pulldown-cmark 0.13.3", "pulldown-cmark 0.13.3",
"quick-xml 0.41.0", "quick-xml 0.41.0",
@@ -832,6 +841,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"url", "url",
"usearch",
"uuid", "uuid",
"walkdir", "walkdir",
] ]
@@ -1188,6 +1198,15 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.58" version = "1.2.58"
@@ -1337,6 +1356,17 @@ dependencies = [
"unicode-width 0.1.14", "unicode-width 0.1.14",
] ]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width 0.2.2",
]
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@@ -1390,6 +1420,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compact_str"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"serde",
"static_assertions",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.37" version = "0.4.37"
@@ -1495,10 +1540,29 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"percent-encoding",
"time", "time",
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie 0.18.1",
"document-features",
"idna",
"indexmap 2.13.0",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -1684,6 +1748,68 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "cxx"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291"
dependencies = [
"cc",
"cxx-build",
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
"foldhash 0.2.0",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2"
dependencies = [
"cc",
"codespan-reporting 0.13.1",
"indexmap 2.13.0",
"proc-macro2",
"quote",
"scratch",
"syn 3.0.0",
]
[[package]]
name = "cxxbridge-cmd"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d"
dependencies = [
"clap",
"codespan-reporting 0.13.1",
"indexmap 2.13.0",
"proc-macro2",
"quote",
"syn 3.0.0",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89"
[[package]]
name = "cxxbridge-macro"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c"
dependencies = [
"indexmap 2.13.0",
"proc-macro2",
"quote",
"syn 3.0.0",
]
[[package]] [[package]]
name = "d3d12" name = "d3d12"
version = "0.19.0" version = "0.19.0"
@@ -1711,14 +1837,38 @@ dependencies = [
"zbus 4.4.0", "zbus 4.4.0",
] ]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.21.3",
"darling_macro", "darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
] ]
[[package]] [[package]]
@@ -1735,17 +1885,37 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.21.3" version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.21.3",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dary_heap"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -1788,6 +1958,37 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1985,6 +2186,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dom_query" name = "dom_query"
version = "0.27.0" version = "0.27.0"
@@ -2064,7 +2274,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
dependencies = [ dependencies = [
"darling", "darling 0.21.3",
"either", "either",
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -2212,6 +2422,12 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "esaxx-rs"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
[[package]] [[package]]
name = "etagere" name = "etagere"
version = "0.2.15" version = "0.2.15"
@@ -2273,6 +2489,22 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]]
name = "fastembed"
version = "5.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c8600c9ec79b51d60c19911fe14eac04fe9c2895e87d2a3e80e2213d645a32"
dependencies = [
"anyhow",
"hf-hub",
"ndarray",
"ort",
"safetensors",
"serde",
"serde_json",
"tokenizers",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -3114,6 +3346,32 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hf-hub"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213"
dependencies = [
"dirs 6.0.0",
"http 1.4.0",
"indicatif",
"libc",
"log",
"rand 0.9.2",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
"ureq",
"windows-sys 0.61.2",
]
[[package]]
name = "hmac-sha256"
version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[package]] [[package]]
name = "htmd" name = "htmd"
version = "0.5.4" version = "0.5.4"
@@ -3250,7 +3508,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@@ -3705,6 +3963,19 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "indicatif"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4"
dependencies = [
"console",
"portable-atomic",
"unicode-width 0.2.2",
"unit-prefix",
"web-time",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@@ -4185,6 +4456,15 @@ dependencies = [
"x11", "x11",
] ]
[[package]]
name = "link-cplusplus"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.6"
@@ -4269,6 +4549,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "local-channel" name = "local-channel"
version = "0.1.5" version = "0.1.5"
@@ -4412,6 +4698,28 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "lzma-rust2"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520"
dependencies = [
"macro_rules_attribute-proc_macro",
"paste",
]
[[package]]
name = "macro_rules_attribute-proc_macro"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -4450,6 +4758,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@@ -4625,6 +4943,28 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "monostate"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
dependencies = [
"monostate-impl",
"serde",
"serde_core",
]
[[package]]
name = "monostate-impl"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "moxcms" name = "moxcms"
version = "0.8.1" version = "0.8.1"
@@ -4669,7 +5009,7 @@ checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843"
dependencies = [ dependencies = [
"bit-set 0.5.3", "bit-set 0.5.3",
"bitflags 2.11.0", "bitflags 2.11.0",
"codespan-reporting", "codespan-reporting 0.11.1",
"hexf-parse", "hexf-parse",
"indexmap 2.13.0", "indexmap 2.13.0",
"log", "log",
@@ -4681,6 +5021,21 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -4774,6 +5129,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -4843,6 +5207,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "numkong"
version = "7.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58d4bb97df102ebdde66a352a20c0bc65c7c407ee9bef12ebe317edc2458a55"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "objc" name = "objc"
version = "0.2.7" version = "0.2.7"
@@ -5338,6 +5711,30 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "ort"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"ndarray",
"ort-sys",
"smallvec",
"tracing",
"ureq",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
dependencies = [
"hmac-sha256",
"lzma-rust2",
"ureq",
]
[[package]] [[package]]
name = "owned_ttf_parser" name = "owned_ttf_parser"
version = "0.25.1" version = "0.25.1"
@@ -5357,7 +5754,7 @@ dependencies = [
"actix-web", "actix-web",
"anyhow", "anyhow",
"async-compression", "async-compression",
"base64", "base64 0.22.1",
"bit-set 0.10.0", "bit-set 0.10.0",
"clap", "clap",
"console", "console",
@@ -5736,7 +6133,7 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml 0.38.4", "quick-xml 0.38.4",
"serde", "serde",
@@ -5798,6 +6195,21 @@ dependencies = [
"nom 7.1.3", "nom 7.1.3",
] ]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "portpicker" name = "portpicker"
version = "0.1.1" version = "0.1.1"
@@ -6217,6 +6629,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.11.0" version = "1.11.0"
@@ -6227,6 +6645,17 @@ dependencies = [
"rayon-core", "rayon-core",
] ]
[[package]]
name = "rayon-cond"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
dependencies = [
"either",
"itertools",
"rayon",
]
[[package]] [[package]]
name = "rayon-core" name = "rayon-core"
version = "1.13.0" version = "1.13.0"
@@ -6352,7 +6781,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -6376,12 +6805,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@@ -6569,6 +7000,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -6627,6 +7059,19 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safetensors"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846"
dependencies = [
"hashbrown 0.16.1",
"libc",
"serde",
"serde_json",
"tempfile",
]
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -6648,6 +7093,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scratch"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]] [[package]]
name = "sctk-adwaita" name = "sctk-adwaita"
version = "0.10.1" version = "0.10.1"
@@ -7104,6 +7555,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]] [[package]]
name = "softbuffer" name = "softbuffer"
version = "0.4.8" version = "0.4.8"
@@ -7171,6 +7633,18 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
] ]
[[package]]
name = "spm_precompiled"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
dependencies = [
"base64 0.13.1",
"nom 7.1.3",
"serde",
"unicode-segmentation",
]
[[package]] [[package]]
name = "sqlite-wasm-rs" name = "sqlite-wasm-rs"
version = "0.5.5" version = "0.5.5"
@@ -7296,6 +7770,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -7589,6 +8074,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokenizers"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
dependencies = [
"ahash 0.8.12",
"aho-corasick",
"compact_str",
"dary_heap",
"derive_builder",
"esaxx-rs",
"getrandom 0.3.4",
"itertools",
"log",
"macro_rules_attribute",
"monostate",
"onig",
"paste",
"rand 0.9.2",
"rayon",
"rayon-cond",
"regex",
"regex-syntax",
"serde",
"serde_json",
"spm_precompiled",
"thiserror 2.0.18",
"unicode-normalization-alignments",
"unicode-segmentation",
"unicode_categories",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@@ -8007,6 +8525,15 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unicode-normalization-alignments"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
dependencies = [
"smallvec",
]
[[package]] [[package]]
name = "unicode-properties" name = "unicode-properties"
version = "0.1.4" version = "0.1.4"
@@ -8049,6 +8576,18 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -8061,6 +8600,39 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http 1.4.0",
"httparse",
"log",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -8080,13 +8652,24 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "usearch"
version = "2.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd7f672d20412962c457b11c858c6c5aecb949808a5345a95e1d671112bcf72"
dependencies = [
"cxx",
"cxx-build",
"numkong",
]
[[package]] [[package]]
name = "usvg" name = "usvg"
version = "0.42.0" version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"data-url", "data-url",
"flate2", "flate2",
"fontdb 0.18.0", "fontdb 0.18.0",
@@ -8119,6 +8702,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -8298,6 +8887,19 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-timer" name = "wasm-timer"
version = "0.2.5" version = "0.2.5"
@@ -8637,7 +9239,7 @@ dependencies = [
"bit-vec 0.6.3", "bit-vec 0.6.3",
"bitflags 2.11.0", "bitflags 2.11.0",
"cfg_aliases 0.1.1", "cfg_aliases 0.1.1",
"codespan-reporting", "codespan-reporting 0.11.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"log", "log",
"naga", "naga",
@@ -9440,7 +10042,7 @@ version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"block2 0.6.2", "block2 0.6.2",
"cookie 0.18.1", "cookie 0.18.1",
"crossbeam-channel", "crossbeam-channel",

View File

@@ -52,6 +52,9 @@ diesel_migrations = "2.3.2"
libsqlite3-sys = { version = "0.37.0", features = ["bundled"] } libsqlite3-sys = { version = "0.37.0", features = ["bundled"] }
mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] } mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] }
url = "2.5" url = "2.5"
fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] }
usearch = "2.26"
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] }
# UI framework # UI framework
iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] } iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }

View File

@@ -12,6 +12,7 @@ The project is under active development. Core blogging workflows are broadly ava
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms. - Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.

View File

@@ -132,7 +132,7 @@ Available:
- Fixed `.lua` script file contract. - Fixed `.lua` script file contract.
- Generated and bundled API/type references plus executable macro, transform, and utility examples under `docs/scripting/`. - Generated and bundled API/type references plus executable macro, transform, and utility examples under `docs/scripting/`.
- Manifest-driven runtime, documentation, and completion data with drift checks. - Manifest-driven runtime, documentation, and completion data with drift checks.
- `bds.sync` and `bds.embeddings` remain extension APIs and are not exposed by core. - The embedding extension adds the bDS2-compatible `bds.embeddings` namespace; `bds.sync` remains outside the core API.
## Remaining Core Blocks ## Remaining Core Blocks

View File

@@ -42,17 +42,13 @@ Done:
- Fixed native A2UI cards, charts, forms, lists, metrics, mind maps, tables, and tabs with safe nested fallbacks, persistent stable-ID interaction state, debounced form values, and strictly allowlisted application actions. - Fixed native A2UI cards, charts, forms, lists, metrics, mind maps, tables, and tabs with safe nested fallbacks, persistent stable-ID interaction state, debounced form values, and strictly allowlisted application actions.
- Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings. - Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings.
### Embeddings, semantic search, and duplicates — Open ### Embeddings, semantic search, and duplicates — Done
Existing source contains schema placeholders only. - Lazy, locally cached `multilingual-e5-small` inference with 384-dimensional mean-pooled, normalized vectors and native Core ML acceleration on macOS or DirectML on Windows.
- Project-isolated USearch HNSW indexes with SQLite BLOB vectors as the recovery source, debounced persistence, lifecycle updates, rebuild/backfill, and CLI repair.
Open: - Semantic sidebar search, link ranking, editor tag suggestions, and the bDS2-compatible `bds.embeddings` Lua API.
- Localized duplicate review with exact-match detection, 500-pair pagination, canonical single/batch dismissal, and post navigation.
- Embedding generation and persistence. - Embedding-aware filesystem rebuild and metadata diff/repair, settings gating, project-switch/shutdown flushes, and native menu commands.
- Vector index and semantic search.
- Expose `bds.embeddings` after those engines exist.
- Duplicate detection, dismissed-pair handling, metadata integrity, and UI.
- Replace the Find Duplicates placeholder.
### Translation QA and documentation UX — Partly done ### Translation QA and documentation UX — Partly done

View File

@@ -317,12 +317,6 @@ fn active_project(db: &Database) -> Result<(Project, PathBuf)> {
fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> { fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
let (project, data_dir) = active_project(db)?; let (project, data_dir) = active_project(db)?;
let metadata = engine::meta::read_project_json(&data_dir)?;
if metadata.semantic_similarity_enabled {
bail!(
"semantic similarity is enabled, but rebuild cannot continue without the real on-device embedding engine"
);
}
if incremental { if incremental {
let (applied, imported, failed) = cli_sync::run_cli_mutation(db.conn(), || { let (applied, imported, failed) = cli_sync::run_cli_mutation(db.conn(), || {
let report = let report =
@@ -490,9 +484,13 @@ fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
RepairPart::Embeddings => { RepairPart::Embeddings => {
let metadata = engine::meta::read_project_json(&data_dir)?; let metadata = engine::meta::read_project_json(&data_dir)?;
if metadata.semantic_similarity_enabled { if metadata.semantic_similarity_enabled {
bail!( let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir);
"semantic similarity is enabled, but no real on-device embedding engine is registered" let indexed = service.reindex_all(&project.id)?;
); service.flush_project(&project.id)?;
return Ok(output(
"Embedding index rebuilt",
json!({"rebuilt": indexed.len(), "disabled": false}),
));
} }
Ok(output( Ok(output(
"Embedding repair skipped because semantic similarity is disabled", "Embedding repair skipped because semantic similarity is disabled",
@@ -1431,8 +1429,9 @@ mod tests {
let mut metadata = engine::meta::read_project_json(&fixture.project_dir).unwrap(); let mut metadata = engine::meta::read_project_json(&fixture.project_dir).unwrap();
metadata.semantic_similarity_enabled = true; metadata.semantic_similarity_enabled = true;
engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap(); engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
assert!(fixture.run(&["repair", "embeddings"], "").is_err()); let repaired = fixture.run(&["repair", "embeddings"], "").unwrap();
assert!(fixture.run(&["rebuild"], "").is_err()); assert_eq!(repaired.data["rebuilt"], 0);
fixture.run(&["rebuild"], "").unwrap();
metadata.semantic_similarity_enabled = false; metadata.semantic_similarity_enabled = false;
engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap(); engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
fixture.run(&["render"], "").unwrap(); fixture.run(&["render"], "").unwrap();

View File

@@ -38,6 +38,9 @@ mlua = { workspace = true }
url = { workspace = true } url = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
fastembed = { workspace = true }
usearch = { workspace = true }
ort = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -258,9 +258,12 @@ CREATE TABLE IF NOT EXISTS embedding_keys (
post_id TEXT NOT NULL, post_id TEXT NOT NULL,
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
content_hash TEXT NOT NULL, content_hash TEXT NOT NULL,
vector TEXT NOT NULL vector BLOB NOT NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS embedding_keys_project_post_idx
ON embedding_keys(project_id, post_id);
CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs ( CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id), project_id TEXT NOT NULL REFERENCES projects(id),

View File

@@ -0,0 +1,15 @@
-- This file should undo anything in `up.sql`
CREATE TABLE embedding_keys_old (
label INTEGER NOT NULL PRIMARY KEY,
post_id TEXT NOT NULL,
project_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
vector TEXT NOT NULL
);
INSERT INTO embedding_keys_old (label, post_id, project_id, content_hash, vector)
SELECT label, post_id, project_id, content_hash, CAST(vector AS TEXT)
FROM embedding_keys;
DROP TABLE embedding_keys;
ALTER TABLE embedding_keys_old RENAME TO embedding_keys;

View File

@@ -0,0 +1,18 @@
-- Your SQL goes here
CREATE TABLE embedding_keys_new (
label INTEGER NOT NULL PRIMARY KEY,
post_id TEXT NOT NULL,
project_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
vector BLOB NOT NULL
);
INSERT INTO embedding_keys_new (label, post_id, project_id, content_hash, vector)
SELECT label, post_id, project_id, content_hash, CAST(vector AS BLOB)
FROM embedding_keys;
DROP TABLE embedding_keys;
ALTER TABLE embedding_keys_new RENAME TO embedding_keys;
CREATE UNIQUE INDEX embedding_keys_project_post_idx
ON embedding_keys(project_id, post_id);

View File

@@ -36,7 +36,7 @@ mod tests {
let applied = db let applied = db
.conn() .conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len()); .with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 6); assert_eq!(applied, 7);
} }
#[test] #[test]

View File

@@ -0,0 +1,283 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::schema::{dismissed_duplicate_pairs, embedding_keys};
use crate::model::{DismissedDuplicatePair, EmbeddingKey};
pub fn get_key_for_post(
conn: &DbConnection,
project_id: &str,
post_id: &str,
) -> QueryResult<Option<EmbeddingKey>> {
conn.with(|c| {
embedding_keys::table
.filter(embedding_keys::project_id.eq(project_id))
.filter(embedding_keys::post_id.eq(post_id))
.select(EmbeddingKey::as_select())
.first(c)
.optional()
})
}
pub fn list_keys(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<EmbeddingKey>> {
conn.with(|c| {
embedding_keys::table
.filter(embedding_keys::project_id.eq(project_id))
.order(embedding_keys::label.asc())
.select(EmbeddingKey::as_select())
.load(c)
})
}
pub fn max_label(conn: &DbConnection) -> QueryResult<i64> {
conn.with(|c| {
embedding_keys::table
.select(embedding_keys::label)
.order(embedding_keys::label.desc())
.first(c)
.optional()
.map(|label| label.unwrap_or(0))
})
}
pub fn upsert_key(conn: &DbConnection, key: &EmbeddingKey) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(embedding_keys::table)
.values(key)
.on_conflict((embedding_keys::project_id, embedding_keys::post_id))
.do_update()
.set((
embedding_keys::content_hash.eq(&key.content_hash),
embedding_keys::vector.eq(&key.vector),
))
.execute(c)
.map(|_| ())
})
}
pub fn delete_key_for_post(
conn: &DbConnection,
project_id: &str,
post_id: &str,
) -> QueryResult<()> {
conn.with(|c| {
diesel::delete(
embedding_keys::table
.filter(embedding_keys::project_id.eq(project_id))
.filter(embedding_keys::post_id.eq(post_id)),
)
.execute(c)
.map(|_| ())
})
}
pub fn delete_stale_keys(
conn: &DbConnection,
project_id: &str,
live_post_ids: &[String],
) -> QueryResult<usize> {
conn.with(|c| {
let query = embedding_keys::table.filter(embedding_keys::project_id.eq(project_id));
if live_post_ids.is_empty() {
diesel::delete(query).execute(c)
} else {
diesel::delete(query.filter(embedding_keys::post_id.ne_all(live_post_ids))).execute(c)
}
})
}
pub fn insert_dismissed_pair(
conn: &DbConnection,
pair: &DismissedDuplicatePair,
) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(dismissed_duplicate_pairs::table)
.values(pair)
.on_conflict((
dismissed_duplicate_pairs::project_id,
dismissed_duplicate_pairs::post_id_a,
dismissed_duplicate_pairs::post_id_b,
))
.do_nothing()
.execute(c)
.map(|_| ())
})
}
pub fn insert_dismissed_pairs(
conn: &DbConnection,
pairs: &[DismissedDuplicatePair],
) -> QueryResult<usize> {
if pairs.is_empty() {
return Ok(0);
}
conn.with(|c| {
diesel::insert_into(dismissed_duplicate_pairs::table)
.values(pairs)
.on_conflict((
dismissed_duplicate_pairs::project_id,
dismissed_duplicate_pairs::post_id_a,
dismissed_duplicate_pairs::post_id_b,
))
.do_nothing()
.execute(c)
})
}
pub fn list_dismissed_pairs(
conn: &DbConnection,
project_id: &str,
) -> QueryResult<Vec<DismissedDuplicatePair>> {
conn.with(|c| {
dismissed_duplicate_pairs::table
.filter(dismissed_duplicate_pairs::project_id.eq(project_id))
.select(DismissedDuplicatePair::as_select())
.load(c)
})
}
pub fn delete_orphan_dismissals(
conn: &DbConnection,
project_id: &str,
live_post_ids: &[String],
) -> QueryResult<usize> {
conn.with(|c| {
let query = dismissed_duplicate_pairs::table
.filter(dismissed_duplicate_pairs::project_id.eq(project_id));
if live_post_ids.is_empty() {
diesel::delete(query).execute(c)
} else {
diesel::delete(
query.filter(
dismissed_duplicate_pairs::post_id_a
.ne_all(live_post_ids)
.or(dismissed_duplicate_pairs::post_id_b.ne_all(live_post_ids)),
),
)
.execute(c)
}
})
}
pub fn delete_dismissals_for_post(
conn: &DbConnection,
project_id: &str,
post_id: &str,
) -> QueryResult<usize> {
conn.with(|c| {
diesel::delete(
dismissed_duplicate_pairs::table
.filter(dismissed_duplicate_pairs::project_id.eq(project_id))
.filter(
dismissed_duplicate_pairs::post_id_a
.eq(post_id)
.or(dismissed_duplicate_pairs::post_id_b.eq(post_id)),
),
)
.execute(c)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::model::{Post, PostStatus, Project};
fn seeded() -> (Database, String, String) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let project_id = "embedding-project".to_string();
crate::db::queries::project::insert_project(
db.conn(),
&Project {
id: project_id.clone(),
name: "Embedding".into(),
slug: "embedding".into(),
description: None,
data_path: Some("/tmp/embedding".into()),
is_active: true,
created_at: 1,
updated_at: 1,
},
)
.unwrap();
let post_id = "embedding-post".to_string();
crate::db::queries::post::insert_post(
db.conn(),
&Post {
id: post_id.clone(),
project_id: project_id.clone(),
title: "Post".into(),
slug: "post".into(),
excerpt: None,
content: Some("Body".into()),
status: PostStatus::Draft,
author: None,
language: Some("en".into()),
do_not_translate: false,
template_slug: None,
file_path: "posts/post.md".into(),
checksum: None,
tags: vec![],
categories: vec![],
published_title: None,
published_content: None,
published_tags: None,
published_categories: None,
published_excerpt: None,
created_at: 1,
updated_at: 1,
published_at: None,
},
)
.unwrap();
(db, project_id, post_id)
}
#[test]
fn embedding_vector_round_trips_as_blob_and_dismissals_are_canonical() {
let (db, project_id, post_id) = seeded();
let key = EmbeddingKey {
label: 1,
post_id: post_id.clone(),
project_id: project_id.clone(),
content_hash: "hash".into(),
vector: vec![0, 1, 255],
};
upsert_key(db.conn(), &key).unwrap();
assert_eq!(
get_key_for_post(db.conn(), &project_id, &post_id).unwrap(),
Some(key)
);
let replacement = EmbeddingKey {
label: 2,
post_id: post_id.clone(),
project_id: project_id.clone(),
content_hash: "new-hash".into(),
vector: vec![3, 2, 1],
};
upsert_key(db.conn(), &replacement).unwrap();
let updated = get_key_for_post(db.conn(), &project_id, &post_id)
.unwrap()
.unwrap();
assert_eq!(updated.label, 1, "a post keeps its stable HNSW label");
assert_eq!(updated.content_hash, "new-hash");
assert_eq!(updated.vector, vec![3, 2, 1]);
let pair = DismissedDuplicatePair {
id: "dismissal".into(),
project_id: project_id.clone(),
post_id_a: "a".into(),
post_id_b: "b".into(),
dismissed_at: 1,
};
insert_dismissed_pair(db.conn(), &pair).unwrap();
insert_dismissed_pair(db.conn(), &pair).unwrap();
assert_eq!(
list_dismissed_pairs(db.conn(), &project_id).unwrap(),
vec![pair]
);
}
}

View File

@@ -1,5 +1,6 @@
pub mod chat; pub mod chat;
pub mod db_notification; pub mod db_notification;
pub mod embedding;
pub mod generated_file_hash; pub mod generated_file_hash;
pub mod import_definition; pub mod import_definition;
pub mod mcp_proposal; pub mod mcp_proposal;

View File

@@ -115,7 +115,7 @@ diesel::table! {
post_id -> Text, post_id -> Text,
project_id -> Text, project_id -> Text,
content_hash -> Text, content_hash -> Text,
vector -> Text, vector -> Binary,
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::db::queries::embedding as qe;
use crate::db::queries::media as qm; use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt; use crate::db::queries::media_translation as qmt;
use crate::db::queries::post as qp; use crate::db::queries::post as qp;
@@ -159,6 +160,49 @@ pub fn compute_metadata_diff(
} }
// 6. Detect orphans // 6. Detect orphans
if crate::engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
let service = crate::engine::embedding::EmbeddingService::production(conn, data_dir);
let keys = qe::list_keys(conn, project_id)?
.into_iter()
.map(|key| (key.post_id.clone(), key))
.collect::<std::collections::HashMap<_, _>>();
for post in &posts {
let expected = service.content_hash_for_post(post)?;
let key = keys.get(&post.id);
let current_hash = key.map(|key| key.content_hash.as_str()).unwrap_or("");
let vector_status = key
.filter(|key| crate::engine::embedding::decode_vector(&key.vector).is_ok())
.map(|_| "ready")
.unwrap_or("missing");
if current_hash != expected || vector_status != "ready" {
let mut fields = Vec::new();
if current_hash != expected {
fields.push(DiffField {
field_name: "content_hash".into(),
db_value: current_hash.into(),
file_value: expected,
});
}
if vector_status != "ready" {
fields.push(DiffField {
field_name: "embedding".into(),
db_value: vector_status.into(),
file_value: "ready".into(),
});
}
report.diffs.push(EntityDiff {
entity_type: "embedding".into(),
entity_id: post.id.clone(),
file_path: format!("projects/{project_id}/embeddings.usearch"),
fields,
});
}
}
}
// 7. Detect orphans
let orphans = detect_orphan_files(conn, data_dir, project_id)?; let orphans = detect_orphan_files(conn, data_dir, project_id)?;
report.orphans = orphans; report.orphans = orphans;
@@ -204,6 +248,11 @@ pub fn repair_metadata_diff_item(
conn, data_dir, project_id, &path, conn, data_dir, project_id, &path,
)?; )?;
} }
"embedding" => {
let post = qp::get_post_by_id(conn, &item.entity_id)?;
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.sync_post(&post)?;
}
other => return unsupported_repair(other), other => return unsupported_repair(other),
} }
} }
@@ -222,6 +271,8 @@ pub fn repair_metadata_diff_item(
)?, )?,
"script" => rewrite_script_from_database(conn, data_dir, &item.entity_id)?, "script" => rewrite_script_from_database(conn, data_dir, &item.entity_id)?,
"template" => rewrite_template_from_database(conn, data_dir, &item.entity_id)?, "template" => rewrite_template_from_database(conn, data_dir, &item.entity_id)?,
"embedding" => crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.flush_project(project_id)?,
other => return unsupported_repair(other), other => return unsupported_repair(other),
}, },
} }

View File

@@ -8,6 +8,7 @@ mod chat_tools;
pub mod cli_launcher; pub mod cli_launcher;
pub mod cli_sync; pub mod cli_sync;
pub mod domain_events; pub mod domain_events;
pub mod embedding;
pub mod error; pub mod error;
pub mod gallery_import; pub mod gallery_import;
pub mod generation; pub mod generation;

View File

@@ -36,7 +36,7 @@ pub struct RebuildReport {
)] )]
pub fn create_post( pub fn create_post(
conn: &Connection, conn: &Connection,
_data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
title: &str, title: &str,
content: Option<&str>, content: Option<&str>,
@@ -91,6 +91,7 @@ pub fn create_post(
fts_index_post(conn, &post)?; fts_index_post(conn, &post)?;
emit_post(&post, NotificationAction::Created); emit_post(&post, NotificationAction::Created);
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
Ok(post) Ok(post)
} }
@@ -102,7 +103,7 @@ pub fn create_post(
)] )]
pub fn update_post( pub fn update_post(
conn: &Connection, conn: &Connection,
_data_dir: &Path, data_dir: &Path,
post_id: &str, post_id: &str,
title: Option<&str>, title: Option<&str>,
slug: Option<&str>, slug: Option<&str>,
@@ -169,7 +170,7 @@ pub fn update_post(
if post.status == PostStatus::Published || post.status == PostStatus::Archived { if post.status == PostStatus::Published || post.status == PostStatus::Archived {
// Reload content from filesystem if content field is NULL (published state) // Reload content from filesystem if content field is NULL (published state)
if post.content.is_none() && !post.file_path.is_empty() { if post.content.is_none() && !post.file_path.is_empty() {
let abs_path = _data_dir.join(&post.file_path); let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() if abs_path.exists()
&& let Ok(file_content) = fs::read_to_string(&abs_path) && let Ok(file_content) = fs::read_to_string(&abs_path)
&& let Ok((_fm, body)) = read_post_file(&file_content) && let Ok((_fm, body)) = read_post_file(&file_content)
@@ -187,6 +188,7 @@ pub fn update_post(
fts_index_post(conn, &post)?; fts_index_post(conn, &post)?;
emit_post(&post, NotificationAction::Updated); emit_post(&post, NotificationAction::Updated);
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
Ok(post) Ok(post)
} }
@@ -210,6 +212,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
Ok(post) => { Ok(post) => {
conn.release_savepoint()?; conn.release_savepoint()?;
emit_post(&post, NotificationAction::Updated); emit_post(&post, NotificationAction::Updated);
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
Ok(post) Ok(post)
} }
Err(error) => { Err(error) => {
@@ -343,7 +346,9 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
} }
let now = now_unix_ms(); let now = now_unix_ms();
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
post.status = PostStatus::Archived;
emit_post(&post, NotificationAction::Updated); emit_post(&post, NotificationAction::Updated);
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
Ok(()) Ok(())
} }
@@ -455,6 +460,7 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
Ok(post) => { Ok(post) => {
conn.release_savepoint()?; conn.release_savepoint()?;
emit_post(&post, NotificationAction::Updated); emit_post(&post, NotificationAction::Updated);
crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post);
Ok(post) Ok(post)
} }
Err(error) => { Err(error) => {
@@ -503,6 +509,8 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR
// Delete post from DB // Delete post from DB
qp::delete_post(conn, post_id)?; qp::delete_post(conn, post_id)?;
crate::engine::embedding::remove_post_best_effort(conn, data_dir, &post.project_id, post_id);
emit_post(&post, NotificationAction::Deleted); emit_post(&post, NotificationAction::Deleted);
Ok(()) Ok(())

View File

@@ -238,6 +238,7 @@ pub fn delete_project(
let is_custom_path = project.data_path.is_some(); let is_custom_path = project.data_path.is_some();
q::delete_project(conn, project_id)?; q::delete_project(conn, project_id)?;
crate::engine::embedding::EmbeddingService::forget_project(project_id);
// Clean up internal filesystem only (not custom external paths per spec) // Clean up internal filesystem only (not custom external paths per spec)
if !is_custom_path if !is_custom_path

View File

@@ -170,15 +170,18 @@ fn rebuild_from_filesystem_inner(
))); )));
} }
progress(0.98, "Refreshing semantic index...");
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
.index_unindexed(project_id)?;
progress(1.0, "Rebuild complete"); progress(1.0, "Rebuild complete");
Ok(report) Ok(report)
} }
fn clear_project_rows(conn: &Connection, project_id: &str) -> EngineResult<()> { fn clear_project_rows(conn: &Connection, project_id: &str) -> EngineResult<()> {
use crate::db::schema::{ use crate::db::schema::{
dismissed_duplicate_pairs, embedding_keys, generated_file_hashes, import_definitions, generated_file_hashes, import_definitions, media, media_translations, post_links,
media, media_translations, post_links, post_media, post_translations, posts, scripts, tags, post_media, post_translations, posts, scripts, tags, templates,
templates,
}; };
let post_ids = crate::db::queries::post::list_posts_by_project(conn, project_id)? let post_ids = crate::db::queries::post::list_posts_by_project(conn, project_id)?
@@ -219,13 +222,6 @@ fn clear_project_rows(conn: &Connection, project_id: &str) -> EngineResult<()> {
generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)), generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)),
) )
.execute(connection)?; .execute(connection)?;
diesel::delete(embedding_keys::table.filter(embedding_keys::project_id.eq(project_id)))
.execute(connection)?;
diesel::delete(
dismissed_duplicate_pairs::table
.filter(dismissed_duplicate_pairs::project_id.eq(project_id)),
)
.execute(connection)?;
diesel::delete( diesel::delete(
import_definitions::table.filter(import_definitions::project_id.eq(project_id)), import_definitions::table.filter(import_definitions::project_id.eq(project_id)),
) )

View File

@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
#[derive(
Debug,
Clone,
PartialEq,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::embedding_keys,
check_for_backend(diesel::sqlite::Sqlite),
treat_none_as_default_value = false
)]
pub struct EmbeddingKey {
pub label: i64,
pub post_id: String,
pub project_id: String,
pub content_hash: String,
pub vector: Vec<u8>,
}
#[derive(
Debug,
Clone,
PartialEq,
Eq,
Serialize,
Deserialize,
diesel::Queryable,
diesel::Selectable,
diesel::Insertable,
diesel::AsChangeset,
)]
#[diesel(
table_name = crate::db::schema::dismissed_duplicate_pairs,
check_for_backend(diesel::sqlite::Sqlite),
treat_none_as_default_value = false
)]
pub struct DismissedDuplicatePair {
pub id: String,
pub project_id: String,
pub post_id_a: String,
pub post_id_b: String,
pub dismissed_at: i64,
}

View File

@@ -1,4 +1,5 @@
mod chat; mod chat;
mod embedding;
mod event; mod event;
mod generation; mod generation;
mod import; mod import;
@@ -12,6 +13,7 @@ mod tag;
mod template; mod template;
pub use chat::{ChatConversation, ChatMessage, ChatRole, NewChatConversation, NewChatMessage}; pub use chat::{ChatConversation, ChatMessage, ChatRole, NewChatConversation, NewChatMessage};
pub use embedding::{DismissedDuplicatePair, EmbeddingKey};
pub use event::DomainEvent; pub use event::DomainEvent;
pub use generation::{ pub use generation::{
DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity, DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity,

View File

@@ -1382,6 +1382,95 @@ impl CoreHost {
(_, response) => one_shot_json(response), (_, response) => one_shot_json(response),
} }
} }
fn embeddings(&self, method: &str, args: &[Value]) -> HostResult<Value> {
let db = self.database()?;
let service = engine::embedding::EmbeddingService::production(db.conn(), &self.data_dir);
match method {
"get_progress" => {
let (indexed, total) = service.indexing_progress(&self.project_id)?;
Ok(json!({"indexed": indexed, "total": total}))
}
"find_similar" => {
let post_id = string_arg(args, 0)?;
self.scoped(
|conn| crate::db::queries::post::get_post_by_id(conn, post_id),
|post| post.project_id.as_str(),
)?;
let limit = args.get(1).and_then(Value::as_u64).unwrap_or(5) as usize;
Ok(Value::Array(
service
.find_similar(post_id, limit)?
.into_iter()
.map(|post| {
json!({
"post_id": post.post_id,
"title": post.title,
"score": post.similarity,
})
})
.collect(),
))
}
"compute_similarities" => {
let post_id = string_arg(args, 0)?;
self.scoped(
|conn| crate::db::queries::post::get_post_by_id(conn, post_id),
|post| post.project_id.as_str(),
)?;
let target_ids = string_array_arg(args, 1)?;
json_value(service.compute_similarities(post_id, &target_ids))
}
"suggest_tags" => {
let post_id = string_arg(args, 0)?;
self.scoped(
|conn| crate::db::queries::post::get_post_by_id(conn, post_id),
|post| post.project_id.as_str(),
)?;
json_value(service.suggest_tags(post_id))
}
"find_duplicates" => {
let mut page = 0;
let pairs = loop {
let result = service.find_duplicates(&self.project_id, page)?;
if !result.has_more {
break result.pairs;
}
page += 1;
};
Ok(Value::Array(
pairs
.into_iter()
.map(|pair| {
json!({
"post_id_a": pair.post_id_a,
"title_a": pair.title_a,
"post_id_b": pair.post_id_b,
"title_b": pair.title_b,
"score": pair.similarity,
"similarity": pair.similarity,
"exact_match": pair.exact_match,
})
})
.collect(),
))
}
"dismiss_pair" => {
let post_id_a = string_arg(args, 0)?;
let post_id_b = string_arg(args, 1)?;
for post_id in [post_id_a, post_id_b] {
self.scoped(
|conn| crate::db::queries::post::get_post_by_id(conn, post_id),
|post| post.project_id.as_str(),
)?;
}
service.dismiss_duplicate_pair(post_id_a, post_id_b)?;
Ok(Value::Bool(true))
}
"index_unindexed_posts" => json_value(service.index_unindexed(&self.project_id)),
_ => Err(format!("unknown embeddings capability: {method}").into()),
}
}
} }
impl HostApi for CoreHost { impl HostApi for CoreHost {
@@ -1398,6 +1487,7 @@ impl HostApi for CoreHost {
"tasks" => self.tasks(method, &arguments), "tasks" => self.tasks(method, &arguments),
"publish" => self.publish(method, &arguments), "publish" => self.publish(method, &arguments),
"chat" => self.chat(method, &arguments), "chat" => self.chat(method, &arguments),
"embeddings" => self.embeddings(method, &arguments),
"bds" if method == "report_progress" => self.report_progress(&arguments), "bds" if method == "report_progress" => self.report_progress(&arguments),
_ => Err(format!("unknown host capability: {namespace}.{method}").into()), _ => Err(format!("unknown host capability: {namespace}.{method}").into()),
}; };
@@ -1537,6 +1627,19 @@ fn string_arg(args: &[Value], index: usize) -> HostResult<&str> {
.ok_or_else(|| format!("argument {} must be a string", index + 1).into()) .ok_or_else(|| format!("argument {} must be a string", index + 1).into())
} }
fn string_array_arg(args: &[Value], index: usize) -> HostResult<Vec<String>> {
args.get(index)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.ok_or_else(|| format!("argument {} must be a table", index + 1).into())
}
fn string_field<'a>(value: &'a Map<String, Value>, field: &str) -> HostResult<&'a str> { fn string_field<'a>(value: &'a Map<String, Value>, field: &str) -> HostResult<&'a str> {
value value
.get(field) .get(field)
@@ -1821,6 +1924,9 @@ mod tests {
upload = upload, upload = upload,
timestamp = post.created_at, timestamp = post.created_at,
project_path = bds.app.get_default_project_path(), project_path = bds.app.get_default_project_path(),
embedding_progress = bds.embeddings.get_progress(),
embedding_backfill = bds.embeddings.index_unindexed_posts(),
foreign_embedding = bds.embeddings.find_similar(input.foreign_post, 5),
} }
end end
"#, "#,
@@ -1842,5 +1948,11 @@ mod tests {
assert!(result.value["upload"].is_null()); assert!(result.value["upload"].is_null());
assert!(result.value["timestamp"].as_str().unwrap().contains('T')); assert!(result.value["timestamp"].as_str().unwrap().contains('T'));
assert_eq!(manager.progress(task_id), Some(0.5)); assert_eq!(manager.progress(task_id), Some(0.5));
assert_eq!(
result.value["embedding_progress"],
json!({"indexed": 0, "total": 1})
);
assert_eq!(result.value["embedding_backfill"], json!([]));
assert!(result.value["foreign_embedding"].is_null());
} }
} }

View File

@@ -456,6 +456,7 @@ mod tests {
[ [
"app", "app",
"chat", "chat",
"embeddings",
"media", "media",
"meta", "meta",
"posts", "posts",
@@ -473,7 +474,7 @@ mod tests {
function main() function main()
return { return {
sync = bds.sync, sync = bds.sync,
embeddings = bds.embeddings, embeddings = type(bds.embeddings),
report_progress = type(bds.report_progress), report_progress = type(bds.report_progress),
post_search = type(bds.posts.search), post_search = type(bds.posts.search),
app_toast = type(bds.app.toast), app_toast = type(bds.app.toast),
@@ -492,6 +493,7 @@ mod tests {
"report_progress": "function", "report_progress": "function",
"post_search": "function", "post_search": "function",
"app_toast": "function", "app_toast": "function",
"embeddings": "table",
}) })
); );
} }
@@ -514,6 +516,7 @@ mod tests {
bds.tasks.status_snapshot(), bds.tasks.status_snapshot(),
bds.publish.upload_site({}), bds.publish.upload_site({}),
bds.chat.detect_post_language("title", "body"), bds.chat.detect_post_language("title", "body"),
bds.embeddings.get_progress(),
} }
end end
"#, "#,
@@ -525,8 +528,8 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(execution.value.as_array().unwrap().len(), 11); assert_eq!(execution.value.as_array().unwrap().len(), 12);
assert_eq!(host.0.lock().unwrap().len(), 11); assert_eq!(host.0.lock().unwrap().len(), 12);
} }
#[test] #[test]

View File

@@ -33,6 +33,7 @@ use crate::views::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth, DashboardTimelineMonth,
}, },
duplicates::DuplicatesState,
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState}, git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
import_editor::{ import_editor::{
ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent, ImportAnalysisEvent, ImportEditorMsg, ImportEditorState, ImportExecutionEvent,
@@ -241,6 +242,23 @@ pub enum Message {
result: Result<engine::generation::GenerationReport, String>, result: Result<engine::generation::GenerationReport, String>,
}, },
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>), SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
DuplicatesRefresh,
DuplicatesLoaded(Result<engine::embedding::DuplicateSearchResult, String>),
DuplicatesToggle(String, String),
DuplicatesCheckAll,
DuplicatesUncheckAll,
DuplicatesDismiss(String, String),
DuplicatesDismissSelected,
DuplicatesDismissed(Result<(), String>),
DuplicatesShowMore,
DuplicatesOpenPost(String),
EmbeddingReindex,
EmbeddingBackfill,
LoadSemanticTagSuggestions(String),
SemanticTagSuggestionsLoaded {
post_id: String,
result: Result<Vec<String>, String>,
},
// Git // Git
GitRefresh, GitRefresh,
@@ -927,6 +945,7 @@ pub struct BdsApp {
settings_state: Option<SettingsViewState>, settings_state: Option<SettingsViewState>,
dashboard_state: Option<DashboardState>, dashboard_state: Option<DashboardState>,
site_validation_state: SiteValidationState, site_validation_state: SiteValidationState,
duplicates_state: DuplicatesState,
metadata_diff_state: MetadataDiffState, metadata_diff_state: MetadataDiffState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState, translation_validation_state: crate::views::translation_validation::TranslationValidationState,
git_state: GitUiState, git_state: GitUiState,
@@ -1091,6 +1110,7 @@ impl BdsApp {
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
site_validation_state: SiteValidationState::default(), site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
metadata_diff_state: MetadataDiffState::default(), metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(), translation_validation_state: Default::default(),
git_state: GitUiState::default(), git_state: GitUiState::default(),
@@ -1169,6 +1189,7 @@ impl BdsApp {
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
site_validation_state: SiteValidationState::default(), site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
metadata_diff_state: MetadataDiffState::default(), metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(), translation_validation_state: Default::default(),
git_state: GitUiState::default(), git_state: GitUiState::default(),
@@ -1495,14 +1516,22 @@ impl BdsApp {
Message::OpenTab(tab) => { Message::OpenTab(tab) => {
self.flush_active_post_editor(); self.flush_active_post_editor();
let idx = tabs::open_tab(&mut self.tabs, tab); let idx = tabs::open_tab(&mut self.tabs, tab);
let mut semantic_post_id = None;
if let Some(t) = self.tabs.get(idx) { if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone()); self.active_tab = Some(t.id.clone());
let tab_clone = t.clone(); let tab_clone = t.clone();
if tab_clone.tab_type == TabType::Post {
semantic_post_id = Some(tab_clone.id.clone());
}
self.load_editor_for_tab(&tab_clone); self.load_editor_for_tab(&tab_clone);
} }
self.enforce_panel_tab_fallback(); self.enforce_panel_tab_fallback();
self.sync_menu_state(); self.sync_menu_state();
self.sync_embedded_preview_for_active_post() let mut tasks = vec![self.sync_embedded_preview_for_active_post()];
if let Some(post_id) = semantic_post_id {
tasks.push(Task::done(Message::LoadSemanticTagSuggestions(post_id)));
}
Task::batch(tasks)
} }
Message::CloseTab(id) => { Message::CloseTab(id) => {
if self.active_tab.as_deref() == Some(id.as_str()) { if self.active_tab.as_deref() == Some(id.as_str()) {
@@ -1573,17 +1602,24 @@ impl BdsApp {
} }
let sidebar_task = self.refresh_counts(); let sidebar_task = self.refresh_counts();
self.sync_menu_state(); self.sync_menu_state();
sidebar_task Task::batch([sidebar_task, Task::done(Message::EmbeddingBackfill)])
} }
Message::SwitchProject(project_id) => { Message::SwitchProject(project_id) => {
self.project_dropdown_open = false; self.project_dropdown_open = false;
if let Some(ref db) = self.db { if let Some(ref db) = self.db {
if let (Some(outgoing), Some(data_dir)) = (&self.active_project, &self.data_dir)
{
let _ =
engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.flush_project(&outgoing.id);
}
match engine::project::set_active_project(db.conn(), &project_id) { match engine::project::set_active_project(db.conn(), &project_id) {
Ok(()) => { Ok(()) => {
self.reset_git_for_project_change(); self.reset_git_for_project_change();
self.active_project = self.active_project =
self.projects.iter().find(|p| p.id == project_id).cloned(); self.projects.iter().find(|p| p.id == project_id).cloned();
self.preview_session = None; self.preview_session = None;
self.duplicates_state = DuplicatesState::default();
self.hide_embedded_preview(); self.hide_embedded_preview();
self.data_dir = self self.data_dir = self
.active_project .active_project
@@ -1626,7 +1662,11 @@ impl BdsApp {
} }
} }
self.sync_menu_state(); self.sync_menu_state();
Task::batch([self.refresh_counts(), self.refresh_git_if_visible()]) Task::batch([
self.refresh_counts(),
self.refresh_git_if_visible(),
Task::done(Message::EmbeddingBackfill),
])
} }
Message::ProjectSwitched(result) => { Message::ProjectSwitched(result) => {
match result { match result {
@@ -2119,6 +2159,118 @@ impl BdsApp {
} }
Task::none() Task::none()
} }
Message::DuplicatesRefresh => self.start_duplicate_search(0),
Message::DuplicatesShowMore => {
let next = self.duplicates_state.page.saturating_add(1);
self.start_duplicate_search(next)
}
Message::DuplicatesLoaded(result) => {
self.duplicates_state.is_loading = false;
self.duplicates_state.has_run = true;
match result {
Ok(result) => {
self.duplicates_state.result = result;
self.duplicates_state.error = None;
self.duplicates_state.selected.retain(|pair| {
self.duplicates_state.result.pairs.iter().any(|candidate| {
candidate.post_id_a == pair.0 && candidate.post_id_b == pair.1
})
});
}
Err(error) => self.duplicates_state.error = Some(error),
}
Task::none()
}
Message::DuplicatesToggle(a, b) => {
if !self
.duplicates_state
.selected
.remove(&(a.clone(), b.clone()))
{
self.duplicates_state.selected.insert((a, b));
}
Task::none()
}
Message::DuplicatesCheckAll => {
self.duplicates_state.selected = self
.duplicates_state
.result
.pairs
.iter()
.map(|pair| (pair.post_id_a.clone(), pair.post_id_b.clone()))
.collect();
Task::none()
}
Message::DuplicatesUncheckAll => {
self.duplicates_state.selected.clear();
Task::none()
}
Message::DuplicatesDismiss(a, b) => self.dismiss_duplicate_pairs(vec![(a, b)]),
Message::DuplicatesDismissSelected => self
.dismiss_duplicate_pairs(self.duplicates_state.selected.iter().cloned().collect()),
Message::DuplicatesDismissed(result) => {
match result {
Ok(()) => {
self.duplicates_state.selected.clear();
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "duplicates.dismissed"),
);
return self.start_duplicate_search(self.duplicates_state.page);
}
Err(error) => self.notify(ToastLevel::Error, &error),
}
Task::none()
}
Message::DuplicatesOpenPost(post_id) => {
let Some(db) = &self.db else {
return Task::none();
};
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id)
else {
return Task::none();
};
Task::done(Message::OpenTab(Tab {
id: post.id,
tab_type: TabType::Post,
title: post.title,
is_transient: false,
is_dirty: false,
}))
}
Message::EmbeddingReindex => self.start_embedding_reindex(),
Message::EmbeddingBackfill => self.start_embedding_backfill(),
Message::LoadSemanticTagSuggestions(post_id) => {
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let db_path = self.db_path.clone();
let returned_post_id = post_id.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.suggest_tags(&post_id)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::SemanticTagSuggestionsLoaded {
post_id: returned_post_id.clone(),
result,
},
)
}
Message::SemanticTagSuggestionsLoaded { post_id, result } => {
if let (Some(state), Ok(suggestions)) =
(self.post_editors.get_mut(&post_id), result)
{
state.semantic_tag_suggestions = suggestions;
}
Task::none()
}
message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => { message @ (Message::MainWindowLoaded(_) | Message::EmbeddedPreviewReady(_)) => {
self.handle_preview_message(message) self.handle_preview_message(message)
} }
@@ -2129,6 +2281,7 @@ impl BdsApp {
self.refresh_task_snapshots(); self.refresh_task_snapshots();
self.process_chat_events(); self.process_chat_events();
self.persist_due_chat_surface_state(); self.persist_due_chat_surface_state();
let _ = engine::embedding::EmbeddingService::flush_due();
if !self.search_index_rebuild_running { if !self.search_index_rebuild_running {
self.auto_save_due_post_editors(); self.auto_save_due_post_editors();
} }
@@ -2646,6 +2799,151 @@ impl BdsApp {
} }
} }
fn start_duplicate_search(&mut self, page: usize) -> Task<Message> {
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
return Task::none();
};
self.duplicates_state.enabled = engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled);
self.duplicates_state.page = page;
self.duplicates_state.error = None;
if !self.duplicates_state.enabled {
self.duplicates_state.is_loading = false;
self.duplicates_state.has_run = false;
self.duplicates_state.result = Default::default();
return Task::none();
}
self.duplicates_state.is_loading = true;
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
let project_id = project.id.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.find_duplicates(&project_id, page)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
Message::DuplicatesLoaded,
)
}
fn dismiss_duplicate_pairs(&mut self, pairs: Vec<(String, String)>) -> Task<Message> {
if pairs.is_empty() {
return Task::none();
}
let (Some(_), Some(data_dir)) = (&self.db, &self.data_dir) else {
return Task::none();
};
let db_path = self.db_path.clone();
let data_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.dismiss_duplicate_pairs(&pairs)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
Message::DuplicatesDismissed,
)
}
fn start_embedding_reindex(&mut self) -> Task<Message> {
let Some(data_dir) = &self.data_dir else {
return Task::none();
};
if !engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
self.notify(
ToastLevel::Warning,
&t(self.ui_locale, "duplicates.disabled"),
);
return Task::none();
}
let locale = self.ui_locale;
self.spawn_engine_task(
"menu.item.rebuildEmbeddingIndex",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir);
let indexed = service
.reindex_all_with_progress(&project_id, |current, total| {
tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"embeddings.indexingProgress",
&[
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
!tm.is_cancelled(tid)
})
.map_err(|error| error.to_string())?;
service
.flush_project(&project_id)
.map_err(|error| error.to_string())?;
Ok(tw(
locale,
"embeddings.reindexed",
&[("count", &indexed.len().to_string())],
))
},
)
}
fn start_embedding_backfill(&mut self) -> Task<Message> {
let Some(data_dir) = &self.data_dir else {
return Task::none();
};
if !engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{
return Task::none();
}
let locale = self.ui_locale;
self.spawn_engine_task(
"embeddings.indexing",
move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let indexed = engine::embedding::EmbeddingService::production(db.conn(), &data_dir)
.index_unindexed_with_progress(&project_id, |current, total| {
tm.report_progress(
tid,
Some(current as f32 / total.max(1) as f32),
Some(tw(
locale,
"embeddings.indexingProgress",
&[
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
!tm.is_cancelled(tid)
})
.map_err(|error| error.to_string())?;
Ok(tw(
locale,
"embeddings.indexed",
&[("count", &indexed.len().to_string())],
))
},
)
}
pub fn view(&self) -> Element<'_, Message> { pub fn view(&self) -> Element<'_, Message> {
let active_name = self.active_project.as_ref().map(|p| p.name.as_str()); let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
let active_post_filter = match self.sidebar_view { let active_post_filter = match self.sidebar_view {
@@ -2706,6 +3004,7 @@ impl BdsApp {
self.settings_state.as_ref(), self.settings_state.as_ref(),
self.dashboard_state.as_ref(), self.dashboard_state.as_ref(),
&self.site_validation_state, &self.site_validation_state,
&self.duplicates_state,
&self.metadata_diff_state, &self.metadata_diff_state,
&self.translation_validation_state, &self.translation_validation_state,
&self.git_state, &self.git_state,
@@ -3397,6 +3696,11 @@ impl BdsApp {
} }
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
MenuAction::ReindexText => Task::done(Message::ReindexText), MenuAction::ReindexText => Task::done(Message::ReindexText),
MenuAction::RebuildEmbeddingIndex => Task::done(Message::EmbeddingReindex),
MenuAction::FindDuplicates => {
self.open_singleton_tab(TabType::FindDuplicates, "tabBar.findDuplicates");
Task::done(Message::DuplicatesRefresh)
}
MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff), MenuAction::MetadataDiff => Task::done(Message::RunMetadataDiff),
MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar), MenuAction::RegenerateCalendar => Task::done(Message::RegenerateCalendar),
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations), MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
@@ -4777,7 +5081,7 @@ impl BdsApp {
..Default::default() ..Default::default()
}; };
let ids = bds_core::db::fts::search_posts_filtered( let mut ids = bds_core::db::fts::search_posts_filtered(
db.conn(), db.conn(),
query, query,
&self.content_language, &self.content_language,
@@ -4786,6 +5090,19 @@ impl BdsApp {
.map(|results| results.post_ids) .map(|results| results.post_ids)
.unwrap_or_default(); .unwrap_or_default();
if let Some(data_dir) = &self.data_dir
&& let Ok(scores) = engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.compute_similarities(current_post_id, &ids)
{
ids.sort_by(|a, b| {
scores
.get(b)
.copied()
.unwrap_or_default()
.total_cmp(&scores.get(a).copied().unwrap_or_default())
});
}
ids.into_iter() ids.into_iter()
.filter_map(|post_id| { .filter_map(|post_id| {
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok() bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
@@ -6199,6 +6516,7 @@ impl BdsApp {
blog_languages: Vec::new(), blog_languages: Vec::new(),
}, },
); );
let semantic_was_enabled = meta.semantic_similarity_enabled;
meta.name = state.project_name.clone(); meta.name = state.project_name.clone();
meta.description = { meta.description = {
let value = state.project_description.text(); let value = state.project_description.text();
@@ -6249,6 +6567,8 @@ impl BdsApp {
let file_result = engine::meta::write_project_json(data_dir, &meta); let file_result = engine::meta::write_project_json(data_dir, &meta);
match (db_result, file_result) { match (db_result, file_result) {
(Ok(()), Ok(())) => { (Ok(()), Ok(())) => {
let semantic_should_backfill =
state.semantic_similarity_enabled && !semantic_was_enabled;
if let Some(listing) = if let Some(listing) =
self.projects.iter_mut().find(|p| p.id == project.id) self.projects.iter_mut().find(|p| p.id == project.id)
{ {
@@ -6258,6 +6578,9 @@ impl BdsApp {
self.blog_languages = state.blog_languages.clone(); self.blog_languages = state.blog_languages.clone();
self.dashboard_state = Some(self.hydrate_dashboard_state()); self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
if semantic_should_backfill {
return Task::done(Message::EmbeddingBackfill);
}
} }
(Err(e), _) => self.notify_operation_failed("common.save", e), (Err(e), _) => self.notify_operation_failed("common.save", e),
(_, Err(e)) => self.notify_operation_failed("common.save", e), (_, Err(e)) => self.notify_operation_failed("common.save", e),
@@ -7927,6 +8250,12 @@ impl BdsApp {
} }
} }
impl Drop for BdsApp {
fn drop(&mut self) {
let _ = engine::embedding::EmbeddingService::flush_all();
}
}
fn content_sample(content: &str, max_len: usize) -> String { fn content_sample(content: &str, max_len: usize) -> String {
content.chars().take(max_len).collect() content.chars().take(max_len).collect()
} }
@@ -9184,7 +9513,10 @@ mod tests {
let mut app = make_app(db, project, &tmp); let mut app = make_app(db, project, &tmp);
let _ = app.refresh_counts(); let _ = app.refresh_counts();
let dash = app.dashboard_state.expect("dashboard state should be set"); let dash = app
.dashboard_state
.clone()
.expect("dashboard state should be set");
let now = chrono::Utc::now(); let now = chrono::Utc::now();
assert_eq!(dash.stats.total_posts, 2); assert_eq!(dash.stats.total_posts, 2);
assert_eq!(dash.stats.published_count, 1); assert_eq!(dash.stats.published_count, 1);
@@ -9552,10 +9884,10 @@ mod tests {
db_path.as_path(), db_path.as_path(),
&project.id, &project.id,
"en", "en",
Some(tmp.path()),
&filter, &filter,
false, false,
50, (50, 0),
0,
); );
assert_eq!(posts.len(), 1); assert_eq!(posts.len(), 1);

View File

@@ -148,6 +148,19 @@ impl BdsApp {
} }
state.tags_input.clear(); state.tags_input.clear();
} }
PostEditorMsg::AddSuggestedTag(tag) => {
if !state
.tags
.iter()
.any(|current| current.eq_ignore_ascii_case(&tag))
{
state.tags.push(tag.clone());
state.mark_dirty();
}
state
.semantic_tag_suggestions
.retain(|candidate| !candidate.eq_ignore_ascii_case(&tag));
}
PostEditorMsg::RemoveTag(tag) => { PostEditorMsg::RemoveTag(tag) => {
state.tags.retain(|t| t != &tag); state.tags.retain(|t| t != &tag);
state.mark_dirty(); state.mark_dirty();

View File

@@ -1,4 +1,5 @@
use super::*; use super::*;
use chrono::Datelike;
impl BdsApp { impl BdsApp {
/// Number of items to load per sidebar page. /// Number of items to load per sidebar page.
@@ -14,6 +15,7 @@ impl BdsApp {
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let project_id = project.id.clone(); let project_id = project.id.clone();
let content_language = self.content_language.clone(); let content_language = self.content_language.clone();
let data_dir = self.data_dir.clone();
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => self.page_filter.clone(), SidebarView::Pages => self.page_filter.clone(),
_ => self.post_filter.clone(), _ => self.post_filter.clone(),
@@ -27,10 +29,10 @@ impl BdsApp {
&db_path, &db_path,
&project_id, &project_id,
&content_language, &content_language,
data_dir.as_deref(),
&filter, &filter,
is_pages, is_pages,
Self::SIDEBAR_PAGE_SIZE + 1, (Self::SIDEBAR_PAGE_SIZE + 1, 0),
0,
) )
}) })
.await .await
@@ -99,6 +101,7 @@ impl BdsApp {
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let project_id = project.id.clone(); let project_id = project.id.clone();
let content_language = self.content_language.clone(); let content_language = self.content_language.clone();
let data_dir = self.data_dir.clone();
let offset = self.sidebar_posts.len() as i64; let offset = self.sidebar_posts.len() as i64;
let filter = match self.sidebar_view { let filter = match self.sidebar_view {
SidebarView::Pages => self.page_filter.clone(), SidebarView::Pages => self.page_filter.clone(),
@@ -113,10 +116,10 @@ impl BdsApp {
&db_path, &db_path,
&project_id, &project_id,
&content_language, &content_language,
data_dir.as_deref(),
&filter, &filter,
is_pages, is_pages,
Self::SIDEBAR_PAGE_SIZE + 1, (Self::SIDEBAR_PAGE_SIZE + 1, offset),
offset,
) )
}) })
.await .await
@@ -163,15 +166,16 @@ impl BdsApp {
db_path: &Path, db_path: &Path,
project_id: &str, project_id: &str,
content_language: &str, content_language: &str,
data_dir: Option<&Path>,
filter: &PostFilter, filter: &PostFilter,
is_pages: bool, is_pages: bool,
limit: i64, pagination: (i64, i64),
offset: i64,
) -> Vec<Post> { ) -> Vec<Post> {
let Ok(db) = Database::open(db_path) else { let Ok(db) = Database::open(db_path) else {
return Vec::new(); return Vec::new();
}; };
let (limit, offset) = pagination;
let params = Self::build_post_filter_params(filter, is_pages); let params = Self::build_post_filter_params(filter, is_pages);
if filter.search_query.trim().is_empty() { if filter.search_query.trim().is_empty() {
return bds_core::db::queries::post::list_posts_filtered( return bds_core::db::queries::post::list_posts_filtered(
@@ -184,6 +188,11 @@ impl BdsApp {
.unwrap_or_default(); .unwrap_or_default();
} }
let semantic_enabled = data_dir.is_some_and(|dir| {
engine::meta::read_project_json(dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
});
let requested = limit.saturating_add(offset).max(0) as usize;
let fts_filters = bds_core::db::fts::PostSearchFilters { let fts_filters = bds_core::db::fts::PostSearchFilters {
status: params.status.as_deref(), status: params.status.as_deref(),
tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()), tags: (!params.tags.is_empty()).then_some(params.tags.as_slice()),
@@ -193,12 +202,16 @@ impl BdsApp {
month: params.month, month: params.month,
from: params.from, from: params.from,
to: params.to, to: params.to,
limit: Some(limit as usize), limit: Some(if semantic_enabled {
offset: Some(offset as usize), requested
} else {
limit as usize
}),
offset: Some(if semantic_enabled { 0 } else { offset as usize }),
..Default::default() ..Default::default()
}; };
let ids = bds_core::db::fts::search_posts_filtered( let fts_ids = bds_core::db::fts::search_posts_filtered(
db.conn(), db.conn(),
&params.search_query, &params.search_query,
content_language, content_language,
@@ -207,11 +220,60 @@ impl BdsApp {
.map(|results| results.post_ids) .map(|results| results.post_ids)
.unwrap_or_default(); .unwrap_or_default();
ids.into_iter() let mut ids = Vec::new();
if semantic_enabled
&& let Some(data_dir) = data_dir
&& let Ok(similar) =
engine::embedding::EmbeddingService::production(db.conn(), data_dir)
.semantic_search(project_id, &params.search_query, requested)
{
ids.extend(similar.into_iter().map(|item| item.post_id));
}
ids.extend(fts_ids);
let mut seen = HashSet::new();
let posts = ids
.into_iter()
.filter(|post_id| seen.insert(post_id.clone()))
.filter_map(|post_id| { .filter_map(|post_id| {
bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok() bds_core::db::queries::post::get_post_by_id(db.conn(), &post_id).ok()
}) })
.filter(|post| post.project_id == project_id) .filter(|post| post.project_id == project_id)
.filter(|post| {
params
.status
.as_ref()
.is_none_or(|status| post.status.as_str() == status)
})
.filter(|post| {
params
.language
.as_ref()
.is_none_or(|language| post.language.as_deref() == Some(language))
})
.filter(|post| {
params
.tags
.iter()
.all(|wanted| post.tags.iter().any(|tag| tag.eq_ignore_ascii_case(wanted)))
})
.filter(|post| {
params.categories.iter().all(|wanted| {
post.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(wanted))
})
})
.filter(|post| params.from.is_none_or(|from| post.created_at >= from))
.filter(|post| params.to.is_none_or(|to| post.created_at <= to))
.filter(|post| {
if params.year.is_none() && params.month.is_none() {
return true;
}
chrono::DateTime::from_timestamp_millis(post.created_at).is_some_and(|date| {
params.year.is_none_or(|year| date.year() == year)
&& params.month.is_none_or(|month| date.month() == month)
})
})
.filter(|post| { .filter(|post| {
let is_page_post = post let is_page_post = post
.categories .categories
@@ -222,8 +284,12 @@ impl BdsApp {
} else { } else {
!is_page_post !is_page_post
} }
}) });
.collect() if semantic_enabled {
posts.skip(offset as usize).take(limit as usize).collect()
} else {
posts.collect()
}
} }
pub(super) fn query_sidebar_media_blocking( pub(super) fn query_sidebar_media_blocking(

View File

@@ -35,6 +35,8 @@ pub enum MenuAction {
EditMenu, EditMenu,
RebuildDatabase, RebuildDatabase,
ReindexText, ReindexText,
RebuildEmbeddingIndex,
FindDuplicates,
MetadataDiff, MetadataDiff,
RegenerateCalendar, RegenerateCalendar,
ValidateTranslations, ValidateTranslations,
@@ -69,6 +71,8 @@ impl MenuAction {
MenuAction::EditMenu, MenuAction::EditMenu,
MenuAction::RebuildDatabase, MenuAction::RebuildDatabase,
MenuAction::ReindexText, MenuAction::ReindexText,
MenuAction::RebuildEmbeddingIndex,
MenuAction::FindDuplicates,
MenuAction::MetadataDiff, MenuAction::MetadataDiff,
MenuAction::RegenerateCalendar, MenuAction::RegenerateCalendar,
MenuAction::ValidateTranslations, MenuAction::ValidateTranslations,
@@ -101,6 +105,8 @@ impl MenuAction {
"edit_menu" => Self::EditMenu, "edit_menu" => Self::EditMenu,
"rebuild_database" => Self::RebuildDatabase, "rebuild_database" => Self::RebuildDatabase,
"reindex_text" => Self::ReindexText, "reindex_text" => Self::ReindexText,
"rebuild_embedding_index" => Self::RebuildEmbeddingIndex,
"find_duplicates" => Self::FindDuplicates,
"metadata_diff" => Self::MetadataDiff, "metadata_diff" => Self::MetadataDiff,
"regenerate_calendar" => Self::RegenerateCalendar, "regenerate_calendar" => Self::RegenerateCalendar,
"validate_translations" => Self::ValidateTranslations, "validate_translations" => Self::ValidateTranslations,
@@ -136,6 +142,8 @@ impl MenuAction {
Self::EditMenu => "menu.item.editMenu", Self::EditMenu => "menu.item.editMenu",
Self::RebuildDatabase => "menu.item.rebuildDatabase", Self::RebuildDatabase => "menu.item.rebuildDatabase",
Self::ReindexText => "menu.item.reindexText", Self::ReindexText => "menu.item.reindexText",
Self::RebuildEmbeddingIndex => "menu.item.rebuildEmbeddingIndex",
Self::FindDuplicates => "menu.item.findDuplicates",
Self::MetadataDiff => "menu.item.metadataDiff", Self::MetadataDiff => "menu.item.metadataDiff",
Self::RegenerateCalendar => "menu.item.regenerateCalendar", Self::RegenerateCalendar => "menu.item.regenerateCalendar",
Self::ValidateTranslations => "menu.item.validateTranslations", Self::ValidateTranslations => "menu.item.validateTranslations",
@@ -173,6 +181,8 @@ pub(crate) fn action_enabled(
| MenuAction::EditMenu | MenuAction::EditMenu
| MenuAction::RebuildDatabase | MenuAction::RebuildDatabase
| MenuAction::ReindexText | MenuAction::ReindexText
| MenuAction::RebuildEmbeddingIndex
| MenuAction::FindDuplicates
| MenuAction::MetadataDiff | MenuAction::MetadataDiff
| MenuAction::RegenerateCalendar | MenuAction::RegenerateCalendar
| MenuAction::ValidateTranslations | MenuAction::ValidateTranslations
@@ -205,6 +215,8 @@ pub(crate) fn action_enabled(
| MenuAction::EditMenu | MenuAction::EditMenu
| MenuAction::RebuildDatabase | MenuAction::RebuildDatabase
| MenuAction::ReindexText | MenuAction::ReindexText
| MenuAction::RebuildEmbeddingIndex
| MenuAction::FindDuplicates
| MenuAction::MetadataDiff | MenuAction::MetadataDiff
| MenuAction::RegenerateCalendar | MenuAction::RegenerateCalendar
| MenuAction::ValidateTranslations | MenuAction::ValidateTranslations
@@ -413,6 +425,13 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::RebuildEmbeddingIndex,
locale,
None,
));
let _ = blog_menu.append(&item(&mut reg, MenuAction::FindDuplicates, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item( let _ = blog_menu.append(&item(

View File

@@ -0,0 +1,182 @@
use std::collections::HashSet;
use bds_core::engine::embedding::DuplicateSearchResult;
use bds_core::i18n::UiLocale;
use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, row, scrollable, text};
use iced::{Color, Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
#[derive(Debug, Clone, Default)]
pub struct DuplicatesState {
pub enabled: bool,
pub is_loading: bool,
pub has_run: bool,
pub page: usize,
pub result: DuplicateSearchResult,
pub selected: HashSet<(String, String)>,
pub error: Option<String>,
}
pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> {
let refresh = if state.is_loading {
button(text(t(locale, "duplicates.searching")).size(13)).style(inputs::secondary_button)
} else {
button(text(t(locale, "common.refresh")).size(13))
.on_press(Message::DuplicatesRefresh)
.style(inputs::secondary_button)
}
.padding([6, 16]);
let dismiss_checked = if state.selected.is_empty() || state.is_loading {
button(text(t(locale, "duplicates.dismissChecked")).size(13))
.style(inputs::secondary_button)
} else {
button(
text(tw(
locale,
"duplicates.dismissCheckedCount",
&[("count", &state.selected.len().to_string())],
))
.size(13),
)
.on_press(Message::DuplicatesDismissSelected)
.style(inputs::primary_button)
}
.padding([6, 16]);
let toolbar = inputs::toolbar(
vec![
text(t(locale, "duplicates.title"))
.size(20)
.shaping(Shaping::Advanced)
.into(),
text(tw(
locale,
"duplicates.count",
&[("count", &state.result.pairs.len().to_string())],
))
.size(12)
.color(inputs::LABEL_COLOR)
.into(),
],
vec![
button(text(t(locale, "duplicates.checkAll")).size(13))
.on_press(Message::DuplicatesCheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
button(text(t(locale, "duplicates.uncheckAll")).size(13))
.on_press(Message::DuplicatesUncheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
dismiss_checked.into(),
refresh.into(),
],
);
let body: Element<'_, Message> = if !state.enabled {
inputs::card(
text(t(locale, "duplicates.disabled"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if state.is_loading && !state.has_run {
inputs::card(
text(t(locale, "duplicates.searching"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if let Some(error) = &state.error {
inputs::card(
text(error.clone())
.size(14)
.color(Color::from_rgb(0.90, 0.38, 0.38)),
)
.into()
} else if state.has_run && state.result.pairs.is_empty() {
inputs::card(
text(t(locale, "duplicates.empty"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else {
let mut pairs = column!().spacing(8);
for pair in &state.result.pairs {
let key = (pair.post_id_a.clone(), pair.post_id_b.clone());
let checked = state.selected.contains(&key);
let badge = if pair.exact_match {
t(locale, "duplicates.exactMatch")
} else {
format!("{:.1}%", pair.similarity * 100.0)
};
pairs = pairs.push(inputs::card(
row![
checkbox("", checked)
.on_toggle({
let a = pair.post_id_a.clone();
let b = pair.post_id_b.clone();
move |_| Message::DuplicatesToggle(a.clone(), b.clone())
})
.size(16),
button(
text(pair.title_a.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_a.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
text("").size(14).color(inputs::LABEL_COLOR),
button(
text(pair.title_b.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_b.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
Space::with_width(Length::Fill),
text(badge).size(12).color(if pair.exact_match {
Color::from_rgb(0.96, 0.68, 0.28)
} else {
Color::from_rgb(0.55, 0.76, 0.92)
}),
button(text(t(locale, "duplicates.dismiss")).size(12))
.on_press(Message::DuplicatesDismiss(
pair.post_id_a.clone(),
pair.post_id_b.clone()
))
.padding([5, 12])
.style(inputs::secondary_button),
]
.spacing(8)
.align_y(iced::Alignment::Center),
));
}
if state.result.has_more {
pairs = pairs.push(
button(text(t(locale, "duplicates.showMore")).size(13))
.on_press(Message::DuplicatesShowMore)
.padding([7, 16])
.style(inputs::secondary_button),
);
}
scrollable(container(pairs).padding(2))
.height(Length::Fill)
.into()
};
container(column![toolbar, body].spacing(12))
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}

View File

@@ -2,6 +2,7 @@ pub mod activity_bar;
pub mod chat_surfaces; pub mod chat_surfaces;
pub mod chat_view; pub mod chat_view;
pub mod dashboard; pub mod dashboard;
pub mod duplicates;
pub mod git; pub mod git;
pub mod import_editor; pub mod import_editor;
pub mod media_editor; pub mod media_editor;

View File

@@ -76,6 +76,7 @@ pub struct PostEditorState {
pub quick_actions_open: bool, pub quick_actions_open: bool,
pub tags_input: String, pub tags_input: String,
pub categories_input: String, pub categories_input: String,
pub semantic_tag_suggestions: Vec<String>,
pub active_language: String, pub active_language: String,
pub canonical_language: String, pub canonical_language: String,
pub blog_languages: Vec<String>, pub blog_languages: Vec<String>,
@@ -123,6 +124,7 @@ impl Clone for PostEditorState {
quick_actions_open: self.quick_actions_open, quick_actions_open: self.quick_actions_open,
tags_input: self.tags_input.clone(), tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(), categories_input: self.categories_input.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
active_language: self.active_language.clone(), active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(), canonical_language: self.canonical_language.clone(),
blog_languages: self.blog_languages.clone(), blog_languages: self.blog_languages.clone(),
@@ -188,6 +190,7 @@ impl PostEditorState {
quick_actions_open: false, quick_actions_open: false,
tags_input: String::new(), tags_input: String::new(),
categories_input: String::new(), categories_input: String::new(),
semantic_tag_suggestions: Vec::new(),
active_language: canonical_lang.clone(), active_language: canonical_lang.clone(),
canonical_language: canonical_lang, canonical_language: canonical_lang,
blog_languages: blog_languages.to_vec(), blog_languages: blog_languages.to_vec(),
@@ -342,6 +345,7 @@ pub enum PostEditorMsg {
SwitchLanguage(String), SwitchLanguage(String),
TagsInputChanged(String), TagsInputChanged(String),
TagsInputSubmit, TagsInputSubmit,
AddSuggestedTag(String),
RemoveTag(String), RemoveTag(String),
CategoriesInputChanged(String), CategoriesInputChanged(String),
CategoriesInputSubmit, CategoriesInputSubmit,
@@ -619,6 +623,28 @@ pub fn view<'a>(
Message::PostEditor(PostEditorMsg::TagsInputSubmit), Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)), |tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
); );
let semantic_tags: Element<'a, Message> = if state.semantic_tag_suggestions.is_empty() {
Space::new(0, 0).into()
} else {
let mut chips = row![
text(t(locale, "editor.semanticTagSuggestions"))
.size(11)
.color(inputs::LABEL_COLOR)
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in &state.semantic_tag_suggestions {
chips = chips.push(
button(text(format!("+ {tag}")).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.clone(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
chips.into()
};
// Categories chip input // Categories chip input
let categories_section = chip_input_field( let categories_section = chip_input_field(
@@ -776,6 +802,7 @@ pub fn view<'a>(
meta_row1, meta_row1,
meta_row2, meta_row2,
tags_section, tags_section,
semantic_tags,
categories_section, categories_section,
outlinks_section, outlinks_section,
backlinks_section, backlinks_section,

View File

@@ -18,6 +18,7 @@ use crate::views::{
activity_bar, activity_bar,
chat_view::{self, ChatEditorState}, chat_view::{self, ChatEditorState},
dashboard::DashboardState, dashboard::DashboardState,
duplicates::{self, DuplicatesState},
git::{self, GitDiffState, GitUiState}, git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState}, media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState}, metadata_diff::{self, MetadataDiffState},
@@ -128,6 +129,7 @@ pub fn view<'a>(
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState, site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState, metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState, translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState, git_state: &'a GitUiState,
@@ -158,6 +160,7 @@ pub fn view<'a>(
settings_state, settings_state,
dashboard_state, dashboard_state,
site_validation_state, site_validation_state,
duplicates_state,
metadata_diff_state, metadata_diff_state,
translation_validation_state, translation_validation_state,
git_diffs, git_diffs,
@@ -400,6 +403,7 @@ fn route_content_area<'a>(
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState, site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState, metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState, translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>, git_diffs: &'a HashMap<String, GitDiffState>,
@@ -489,6 +493,7 @@ fn route_content_area<'a>(
} }
} }
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale), ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale), ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::TranslationValidation => { ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale) translation_validation::view(translation_validation_state, locale)
@@ -524,6 +529,7 @@ enum ContentRoute<'a> {
Tags, Tags,
Settings, Settings,
SiteValidation, SiteValidation,
FindDuplicates,
MetadataDiff, MetadataDiff,
TranslationValidation, TranslationValidation,
GitDiff(&'a str), GitDiff(&'a str),
@@ -612,11 +618,11 @@ fn route_kind<'a>(
TabType::SiteValidation => ContentRoute::SiteValidation, TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff, TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id), TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::FindDuplicates => ContentRoute::FindDuplicates,
TabType::Style TabType::Style
| TabType::MenuEditor | TabType::MenuEditor
| TabType::Documentation | TabType::Documentation
| TabType::ApiDocumentation | TabType::ApiDocumentation => ContentRoute::Placeholder(&tab.title),
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation, TabType::TranslationValidation => ContentRoute::TranslationValidation,
} }
} }
@@ -678,7 +684,6 @@ mod tests {
TabType::MenuEditor, TabType::MenuEditor,
TabType::Documentation, TabType::Documentation,
TabType::ApiDocumentation, TabType::ApiDocumentation,
TabType::FindDuplicates,
]; ];
for tab_type in unsupported { for tab_type in unsupported {
@@ -703,6 +708,35 @@ mod tests {
} }
} }
#[test]
fn duplicate_tab_routes_to_real_review_surface() {
let tabs = vec![tab(
"find_duplicates",
TabType::FindDuplicates,
"Duplicates",
)];
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation = SiteValidationState::default();
let route = route_kind(
&tabs,
Some("find_duplicates"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::FindDuplicates));
}
#[test] #[test]
fn git_diff_tab_routes_to_real_view() { fn git_diff_tab_routes_to_real_view() {
let empty_posts = HashMap::new(); let empty_posts = HashMap::new();

View File

@@ -32,6 +32,7 @@ Macro scripts expose `render(input, context)` and transform scripts expose `main
- [Root helpers](#root-helpers) - [Root helpers](#root-helpers)
- [`bds.app`](#bdsapp) - [`bds.app`](#bdsapp)
- [`bds.chat`](#bdschat) - [`bds.chat`](#bdschat)
- [`bds.embeddings`](#bdsembeddings)
- [`bds.media`](#bdsmedia) - [`bds.media`](#bdsmedia)
- [`bds.meta`](#bdsmeta) - [`bds.meta`](#bdsmeta)
- [`bds.posts`](#bdsposts) - [`bds.posts`](#bdsposts)
@@ -795,6 +796,230 @@ local result = bds.chat.translate_media_metadata("example-id", "en")
{ key = "value" } { key = "value" }
``` ```
## `bds.embeddings`
### `bds.embeddings.compute_similarities`
Compute similarity scores from one source post to target posts.
**Signature**
```text
bds.embeddings.compute_similarities(post_id: string, target_ids: table) -> table | nil
```
**Parameters**
| Name | Type | Required | Example |
| --- | --- | --- | --- |
| `post_id` | `string` | Yes | `"example-id"` |
| `target_ids` | `table` | Yes | `{ title = "Example" }` |
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.compute_similarities("example-id", { title = "Example" })
```
**Example response**
```lua
{ key = "value" }
```
### `bds.embeddings.dismiss_pair`
Dismiss a duplicate candidate pair.
**Signature**
```text
bds.embeddings.dismiss_pair(post_id_a: string, post_id_b: string) -> boolean
```
**Parameters**
| Name | Type | Required | Example |
| --- | --- | --- | --- |
| `post_id_a` | `string` | Yes | `"example"` |
| `post_id_b` | `string` | Yes | `"example"` |
**Returns**
`boolean`. `false` means the operation was rejected or failed.
**Example call**
```lua
local result = bds.embeddings.dismiss_pair("example", "example")
```
**Example response**
```lua
true
```
### `bds.embeddings.find_duplicates`
Find duplicate post candidates for the current project.
**Signature**
```text
bds.embeddings.find_duplicates() -> table | nil
```
**Parameters**
None.
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.find_duplicates()
```
**Example response**
```lua
{ key = "value" }
```
### `bds.embeddings.find_similar`
Find posts similar to the given post id.
**Signature**
```text
bds.embeddings.find_similar(post_id: string, limit?: integer) -> table | nil
```
**Parameters**
| Name | Type | Required | Example |
| --- | --- | --- | --- |
| `post_id` | `string` | Yes | `"example-id"` |
| `limit` | `integer` | No | `nil` |
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.find_similar("example-id", nil)
```
**Example response**
```lua
{ key = "value" }
```
### `bds.embeddings.get_progress`
Get embedding index progress for the current project.
**Signature**
```text
bds.embeddings.get_progress() -> table | nil
```
**Parameters**
None.
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.get_progress()
```
**Example response**
```lua
{ key = "value" }
```
### `bds.embeddings.index_unindexed_posts`
Index posts missing embeddings for the current project.
**Signature**
```text
bds.embeddings.index_unindexed_posts() -> table | nil
```
**Parameters**
None.
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.index_unindexed_posts()
```
**Example response**
```lua
{ key = "value" }
```
### `bds.embeddings.suggest_tags`
Suggest tags for a post from semantic similarity.
**Signature**
```text
bds.embeddings.suggest_tags(post_id: string, exclude_tags?: table) -> table | nil
```
**Parameters**
| Name | Type | Required | Example |
| --- | --- | --- | --- |
| `post_id` | `string` | Yes | `"example-id"` |
| `exclude_tags` | `table` | No | `{ title = "Example" }` |
**Returns**
`table | nil`. `nil` means no value was available or the host operation failed.
**Example call**
```lua
local result = bds.embeddings.suggest_tags("example-id", { title = "Example" })
```
**Example response**
```lua
{ key = "value" }
```
## `bds.media` ## `bds.media`
### `bds.media.delete_translation` ### `bds.media.delete_translation`

View File

@@ -1543,6 +1543,99 @@
} }
], ],
"returns": "table | nil" "returns": "table | nil"
},
{
"description": "Compute similarity scores from one source post to target posts.",
"name": "compute_similarities",
"namespace": "embeddings",
"params": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "target_ids",
"required": true,
"type": "table"
}
],
"returns": "table | nil"
},
{
"description": "Dismiss a duplicate candidate pair.",
"name": "dismiss_pair",
"namespace": "embeddings",
"params": [
{
"name": "post_id_a",
"required": true,
"type": "string"
},
{
"name": "post_id_b",
"required": true,
"type": "string"
}
],
"returns": "boolean"
},
{
"description": "Find duplicate post candidates for the current project.",
"name": "find_duplicates",
"namespace": "embeddings",
"params": [],
"returns": "table | nil"
},
{
"description": "Find posts similar to the given post id.",
"name": "find_similar",
"namespace": "embeddings",
"params": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "limit",
"required": false,
"type": "integer"
}
],
"returns": "table | nil"
},
{
"description": "Get embedding index progress for the current project.",
"name": "get_progress",
"namespace": "embeddings",
"params": [],
"returns": "table | nil"
},
{
"description": "Index posts missing embeddings for the current project.",
"name": "index_unindexed_posts",
"namespace": "embeddings",
"params": [],
"returns": "table | nil"
},
{
"description": "Suggest tags for a post from semantic similarity.",
"name": "suggest_tags",
"namespace": "embeddings",
"params": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "exclude_tags",
"required": false,
"type": "table"
}
],
"returns": "table | nil"
} }
], ],
"root_methods": [ "root_methods": [

File diff suppressed because one or more lines are too long

View File

@@ -1669,6 +1669,106 @@
], ],
"returns": "table | nil" "returns": "table | nil"
}, },
{
"description": "Compute similarity scores from one source post to target posts.",
"detail": "bds.embeddings.compute_similarities(post_id: string, target_ids: table) -> table | nil",
"insert_text": "bds.embeddings.compute_similarities(\"example-id\", { title = \"Example\" })",
"label": "bds.embeddings.compute_similarities",
"parameters": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "target_ids",
"required": true,
"type": "table"
}
],
"returns": "table | nil"
},
{
"description": "Dismiss a duplicate candidate pair.",
"detail": "bds.embeddings.dismiss_pair(post_id_a: string, post_id_b: string) -> boolean",
"insert_text": "bds.embeddings.dismiss_pair(\"example\", \"example\")",
"label": "bds.embeddings.dismiss_pair",
"parameters": [
{
"name": "post_id_a",
"required": true,
"type": "string"
},
{
"name": "post_id_b",
"required": true,
"type": "string"
}
],
"returns": "boolean"
},
{
"description": "Find duplicate post candidates for the current project.",
"detail": "bds.embeddings.find_duplicates() -> table | nil",
"insert_text": "bds.embeddings.find_duplicates()",
"label": "bds.embeddings.find_duplicates",
"parameters": [],
"returns": "table | nil"
},
{
"description": "Find posts similar to the given post id.",
"detail": "bds.embeddings.find_similar(post_id: string, limit?: integer) -> table | nil",
"insert_text": "bds.embeddings.find_similar(\"example-id\", nil)",
"label": "bds.embeddings.find_similar",
"parameters": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "limit",
"required": false,
"type": "integer"
}
],
"returns": "table | nil"
},
{
"description": "Get embedding index progress for the current project.",
"detail": "bds.embeddings.get_progress() -> table | nil",
"insert_text": "bds.embeddings.get_progress()",
"label": "bds.embeddings.get_progress",
"parameters": [],
"returns": "table | nil"
},
{
"description": "Index posts missing embeddings for the current project.",
"detail": "bds.embeddings.index_unindexed_posts() -> table | nil",
"insert_text": "bds.embeddings.index_unindexed_posts()",
"label": "bds.embeddings.index_unindexed_posts",
"parameters": [],
"returns": "table | nil"
},
{
"description": "Suggest tags for a post from semantic similarity.",
"detail": "bds.embeddings.suggest_tags(post_id: string, exclude_tags?: table) -> table | nil",
"insert_text": "bds.embeddings.suggest_tags(\"example-id\", { title = \"Example\" })",
"label": "bds.embeddings.suggest_tags",
"parameters": [
{
"name": "post_id",
"required": true,
"type": "string"
},
{
"name": "exclude_tags",
"required": false,
"type": "table"
}
],
"returns": "table | nil"
},
{ {
"description": "Report progress for the current managed job.", "description": "Report progress for the current managed job.",
"detail": "bds.report_progress(payload: table) -> boolean", "detail": "bds.report_progress(payload: table) -> boolean",

View File

@@ -19,6 +19,27 @@ menu-item-publishSelected = Ausgewählte veröffentlichen
menu-item-previewPost = Beitragsvorschau menu-item-previewPost = Beitragsvorschau
menu-item-rebuildDatabase = Datenbank aus Dateien neu aufbauen menu-item-rebuildDatabase = Datenbank aus Dateien neu aufbauen
menu-item-reindexText = Suchtext neu indizieren menu-item-reindexText = Suchtext neu indizieren
menu-item-rebuildEmbeddingIndex = Embedding-Index neu erstellen
menu-item-findDuplicates = Doppelte Beiträge finden
common-refresh = Aktualisieren
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
duplicates-title = Doppelte Beiträge finden
duplicates-searching = Duplikate werden gesucht…
duplicates-count = { $count } mögliche Paare
duplicates-disabled = Semantische Ähnlichkeit ist für dieses Projekt nicht aktiviert.
duplicates-empty = Keine möglichen doppelten Beiträge gefunden.
duplicates-checkAll = Alle auswählen
duplicates-uncheckAll = Auswahl aufheben
duplicates-dismissChecked = Ausgewählte verwerfen
duplicates-dismissCheckedCount = Ausgewählte verwerfen ({ $count })
duplicates-exactMatch = Exakte Übereinstimmung
duplicates-dismiss = Verwerfen
duplicates-dismissed = Duplikatpaar wurde verworfen.
duplicates-showMore = Mehr anzeigen
embeddings-reindexed = Embeddings für { $count } Beiträge neu erstellt.
embeddings-indexing = Semantische Inhalte werden indexiert
embeddings-indexingProgress = Beitrag { $current } von { $total } wird indexiert
embeddings-indexed = Der semantische Index ist für { $count } Beiträge bereit.
menu-item-metadataDiff = Metadaten-Diff-Werkzeug menu-item-metadataDiff = Metadaten-Diff-Werkzeug
menu-item-editMenu = Blog-Menü bearbeiten menu-item-editMenu = Blog-Menü bearbeiten
menu-item-generateSitemap = Site rendern menu-item-generateSitemap = Site rendern

View File

@@ -19,6 +19,27 @@ menu-item-publishSelected = Publish Selected
menu-item-previewPost = Preview Post menu-item-previewPost = Preview Post
menu-item-rebuildDatabase = Rebuild Database from Files menu-item-rebuildDatabase = Rebuild Database from Files
menu-item-reindexText = Reindex Search Text menu-item-reindexText = Reindex Search Text
menu-item-rebuildEmbeddingIndex = Rebuild Embedding Index
menu-item-findDuplicates = Find Duplicate Posts
common-refresh = Refresh
editor-semanticTagSuggestions = Suggested from similar posts
duplicates-title = Find Duplicate Posts
duplicates-searching = Searching for duplicates…
duplicates-count = { $count } potential pairs
duplicates-disabled = Semantic similarity is not enabled for this project.
duplicates-empty = No potential duplicate posts found.
duplicates-checkAll = Check All
duplicates-uncheckAll = Uncheck All
duplicates-dismissChecked = Dismiss Checked
duplicates-dismissCheckedCount = Dismiss Checked ({ $count })
duplicates-exactMatch = Exact Match
duplicates-dismiss = Dismiss
duplicates-dismissed = Duplicate pair dismissal saved.
duplicates-showMore = Show More
embeddings-reindexed = Rebuilt embeddings for { $count } posts.
embeddings-indexing = Indexing semantic content
embeddings-indexingProgress = Indexing post { $current } of { $total }
embeddings-indexed = Semantic index is ready for { $count } posts.
menu-item-metadataDiff = Metadata Diff Tool menu-item-metadataDiff = Metadata Diff Tool
menu-item-editMenu = Edit Blog Menu menu-item-editMenu = Edit Blog Menu
menu-item-generateSitemap = Render Site menu-item-generateSitemap = Render Site

View File

@@ -19,6 +19,27 @@ menu-item-publishSelected = Publicar selección
menu-item-previewPost = Vista previa de entrada menu-item-previewPost = Vista previa de entrada
menu-item-rebuildDatabase = Reconstruir base de datos desde archivos menu-item-rebuildDatabase = Reconstruir base de datos desde archivos
menu-item-reindexText = Reindexar texto de búsqueda menu-item-reindexText = Reindexar texto de búsqueda
menu-item-rebuildEmbeddingIndex = Reconstruir índice semántico
menu-item-findDuplicates = Buscar publicaciones duplicadas
common-refresh = Actualizar
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
duplicates-title = Buscar publicaciones duplicadas
duplicates-searching = Buscando duplicados…
duplicates-count = { $count } pares potenciales
duplicates-disabled = La similitud semántica no está activada para este proyecto.
duplicates-empty = No se encontraron publicaciones posiblemente duplicadas.
duplicates-checkAll = Marcar todo
duplicates-uncheckAll = Desmarcar todo
duplicates-dismissChecked = Descartar marcados
duplicates-dismissCheckedCount = Descartar marcados ({ $count })
duplicates-exactMatch = Coincidencia exacta
duplicates-dismiss = Descartar
duplicates-dismissed = Se descartó el par duplicado.
duplicates-showMore = Mostrar más
embeddings-reindexed = Se reconstruyeron los embeddings de { $count } publicaciones.
embeddings-indexing = Indexando contenido semántico
embeddings-indexingProgress = Indexando publicación { $current } de { $total }
embeddings-indexed = El índice semántico está listo para { $count } publicaciones.
menu-item-metadataDiff = Herramienta diff de metadatos menu-item-metadataDiff = Herramienta diff de metadatos
menu-item-editMenu = Editar menú del blog menu-item-editMenu = Editar menú del blog
menu-item-generateSitemap = Renderizar sitio menu-item-generateSitemap = Renderizar sitio

View File

@@ -19,6 +19,27 @@ menu-item-publishSelected = Publier la sélection
menu-item-previewPost = Aperçu de l'article menu-item-previewPost = Aperçu de l'article
menu-item-rebuildDatabase = Reconstruire la base de données menu-item-rebuildDatabase = Reconstruire la base de données
menu-item-reindexText = Réindexer le texte de recherche menu-item-reindexText = Réindexer le texte de recherche
menu-item-rebuildEmbeddingIndex = Reconstruire lindex sémantique
menu-item-findDuplicates = Rechercher les articles en double
common-refresh = Actualiser
editor-semanticTagSuggestions = Suggestions darticles similaires
duplicates-title = Rechercher les articles en double
duplicates-searching = Recherche des doublons…
duplicates-count = { $count } paires potentielles
duplicates-disabled = La similarité sémantique nest pas activée pour ce projet.
duplicates-empty = Aucun doublon potentiel trouvé.
duplicates-checkAll = Tout cocher
duplicates-uncheckAll = Tout décocher
duplicates-dismissChecked = Ignorer la sélection
duplicates-dismissCheckedCount = Ignorer la sélection ({ $count })
duplicates-exactMatch = Correspondance exacte
duplicates-dismiss = Ignorer
duplicates-dismissed = La paire de doublons a été ignorée.
duplicates-showMore = Afficher plus
embeddings-reindexed = Embeddings reconstruits pour { $count } articles.
embeddings-indexing = Indexation du contenu sémantique
embeddings-indexingProgress = Indexation de larticle { $current } sur { $total }
embeddings-indexed = Lindex sémantique est prêt pour { $count } articles.
menu-item-metadataDiff = Outil de diff des métadonnées menu-item-metadataDiff = Outil de diff des métadonnées
menu-item-editMenu = Modifier le menu du blog menu-item-editMenu = Modifier le menu du blog
menu-item-generateSitemap = Rendre le site menu-item-generateSitemap = Rendre le site

View File

@@ -19,6 +19,27 @@ menu-item-publishSelected = Pubblica selezionato
menu-item-previewPost = Anteprima post menu-item-previewPost = Anteprima post
menu-item-rebuildDatabase = Ricostruisci database dai file menu-item-rebuildDatabase = Ricostruisci database dai file
menu-item-reindexText = Reindicizza testo di ricerca menu-item-reindexText = Reindicizza testo di ricerca
menu-item-rebuildEmbeddingIndex = Ricostruisci indice semantico
menu-item-findDuplicates = Trova post duplicati
common-refresh = Aggiorna
editor-semanticTagSuggestions = Suggerimenti da post simili
duplicates-title = Trova post duplicati
duplicates-searching = Ricerca duplicati…
duplicates-count = { $count } coppie potenziali
duplicates-disabled = La somiglianza semantica non è abilitata per questo progetto.
duplicates-empty = Nessun possibile post duplicato trovato.
duplicates-checkAll = Seleziona tutto
duplicates-uncheckAll = Deseleziona tutto
duplicates-dismissChecked = Ignora selezionati
duplicates-dismissCheckedCount = Ignora selezionati ({ $count })
duplicates-exactMatch = Corrispondenza esatta
duplicates-dismiss = Ignora
duplicates-dismissed = Coppia duplicata ignorata.
duplicates-showMore = Mostra altro
embeddings-reindexed = Embedding ricostruiti per { $count } post.
embeddings-indexing = Indicizzazione dei contenuti semantici
embeddings-indexingProgress = Indicizzazione del post { $current } di { $total }
embeddings-indexed = Lindice semantico è pronto per { $count } post.
menu-item-metadataDiff = Strumento diff metadati menu-item-metadataDiff = Strumento diff metadati
menu-item-editMenu = Modifica menu blog menu-item-editMenu = Modifica menu blog
menu-item-generateSitemap = Renderizza sito menu-item-generateSitemap = Renderizza sito

View File

@@ -142,7 +142,9 @@ surface ScriptRuntimeSurface {
-- the core one-shot AI bridge; conversational chat remains extension. -- the core one-shot AI bridge; conversational chat remains extension.
-- bds.report_progress(payload) reports managed-job progress. The Rust -- bds.report_progress(payload) reports managed-job progress. The Rust
-- host may retain bds.app.progress and bds.app.toast as additive APIs. -- host may retain bds.app.progress and bds.app.toast as additive APIs.
-- bds.sync and bds.embeddings belong to the extension contract. -- bds.embeddings: compute_similarities, dismiss_pair, find_duplicates,
-- find_similar, get_progress, index_unindexed_posts, suggest_tags.
-- bds.sync remains a future extension contract.
@guarantee HostApiFailureValues @guarantee HostApiFailureValues
-- Host failures do not expose implementation exceptions across the Lua -- Host failures do not expose implementation exceptions across the Lua