feat: initial text-akoma-normalize v0.1.0 (text.akoma_normalize@0.1.0)
First F∆I capability module that normalises heterogeneous legal
document sources to a structured representation aligned with
Akoma Ntoso 1.0 (OASIS LegalDocML).
v0.1.0 ships one adapter:
- gesetze-im-internet (BMJ) XML — the BMJ XML schema with
jurabk / enbez / titel / textdaten elements. Lossy but
deterministic: SHA-256 of source + SHA-256 of canonicalised
payload land in the output so the Hub audit log can verify
that downstream evaluations were computed against the same
bytes the auditor sees.
Reserved for next versions, without an interface break:
- 0.2: landesrecht-berlin HTML adapter
- 0.3: EUR-Lex FORMEX-4 adapter
- 0.4+: emit akoma_xml (full Akoma Ntoso roundtrip)
ELI synthesis in 0.1 is a deterministic eli/bund/<jurabk>/<para>
fallback because Germany lacks an upstream Bund-level ELI
registrar. Replace once BMJ publishes ELI URIs upstream.
Pure in-WASM (quick-xml + sha2), zero filesystem, zero network —
the empty permissions list in module.yaml makes that explicit
and is enforced by the Hub.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
commit
b73b3e4b73
6 changed files with 1025 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
target/
|
||||
**/*.rs.bk
|
||||
.DS_Store
|
||||
525
Cargo.lock
generated
Normal file
525
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chain-module-sdk"
|
||||
version = "0.1.2"
|
||||
source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?branch=main#98e98e9371f7409560a1ef08bc0923d9a2506449"
|
||||
dependencies = [
|
||||
"chain-module-sdk-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chain-module-sdk-macros"
|
||||
version = "0.1.2"
|
||||
source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?branch=main#98e98e9371f7409560a1ef08bc0923d9a2506449"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text_akoma_normalize"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chain-module-sdk",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
46
Cargo.toml
Normal file
46
Cargo.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Standalone Cargo.toml — targets wasm32-wasip2.
|
||||
#
|
||||
# Build with:
|
||||
# cargo build --release --target wasm32-wasip2
|
||||
|
||||
[package]
|
||||
name = "text_akoma_normalize"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["Dr. Stefan Flemming <chain@flemming.ai>"]
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
description = "F∆I module — normalise heterogeneous legal-document sources to a structured Akoma-Ntoso-aligned representation"
|
||||
repository = "https://git.flemming.ws/chain-modules/text-akoma-normalize"
|
||||
rust-version = "1.85"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
chain-module-sdk = { git = "https://git.flemming.ai/fai/chain-module-sdk-rust.git", branch = "main" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
quick-xml = "0.36"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
print_stdout = "deny"
|
||||
print_stderr = "deny"
|
||||
|
||||
[workspace]
|
||||
# Empty workspace table so this package is NOT picked up by a parent.
|
||||
90
module.yaml
Normal file
90
module.yaml
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
schema_version: 3
|
||||
provider: chain
|
||||
name: text-akoma-normalize
|
||||
version: 0.1.0
|
||||
|
||||
# Capability provided by this module.
|
||||
#
|
||||
# Normalises heterogeneous legal-document sources (gesetze-im-internet
|
||||
# XML, EUR-Lex FORMEX, landesrecht-berlin HTML) to a structured
|
||||
# representation aligned with Akoma Ntoso 1.0 (OASIS LegalDocML).
|
||||
#
|
||||
# v0.1.0 ships the gesetze-im-internet (BMJ) XML adapter only.
|
||||
# HTML and FORMEX adapters are planned for 0.2/0.3 without an
|
||||
# interface break — the output schema is forward-compatible.
|
||||
provides:
|
||||
- capability: text.akoma_normalize
|
||||
version: 0.1.0
|
||||
|
||||
inputs:
|
||||
content:
|
||||
type: bytes
|
||||
description:
|
||||
en: |
|
||||
Source bytes of one legal-document file. Supported MIME types
|
||||
in v0.1.0:
|
||||
application/xml (gesetze-im-internet BMJ schema)
|
||||
text/xml (alias)
|
||||
Planned for 0.2+:
|
||||
text/html (landesrecht-berlin)
|
||||
application/x-formex+xml (EUR-Lex FORMEX-4)
|
||||
de: |
|
||||
Rohbytes einer Rechtsdokument-Datei. Unterstützte MIME-Typen
|
||||
in v0.1.0:
|
||||
application/xml (gesetze-im-internet BMJ-Schema)
|
||||
text/xml (alias)
|
||||
Geplant ab 0.2:
|
||||
text/html (landesrecht-berlin)
|
||||
application/x-formex+xml (EUR-Lex FORMEX-4)
|
||||
mime:
|
||||
type: text
|
||||
optional: true
|
||||
description:
|
||||
en: |
|
||||
Optional MIME type hint. If omitted, the module inspects
|
||||
the leading bytes to choose an adapter. Pass explicitly
|
||||
when the source content-type is known (e.g. from
|
||||
http.request's `content_type` output).
|
||||
de: |
|
||||
Optionaler MIME-Typ-Hinweis. Ohne Hinweis erkennt das Modul
|
||||
den Typ aus den Anfangsbytes. Explizit übergeben, wenn der
|
||||
Quell-Content-Type bekannt ist (z.B. aus http.request's
|
||||
Output `content_type`).
|
||||
|
||||
outputs:
|
||||
norm:
|
||||
type: json
|
||||
description:
|
||||
en: |
|
||||
JSON-encoded NormalizedNorm:
|
||||
{
|
||||
schema_version: "0.1",
|
||||
adapter: "gesetze-im-internet-xml",
|
||||
adapter_version: "0.1.0",
|
||||
eli: "eli/bund/<jurabk>/<paragraph>",
|
||||
jurabk: "GewO",
|
||||
paragraph: "§ 14",
|
||||
title: "Anzeigepflicht",
|
||||
paragraphs: [{ number, text }],
|
||||
source_sha256: "...",
|
||||
normalized_sha256: "...",
|
||||
akoma_xml: "<akomaNtoso>...</akomaNtoso>" // v0.2+, omitted in 0.1
|
||||
}
|
||||
eli is synthetic in 0.1 — Germany lacks an official Bund-level
|
||||
ELI registrar. Synthesis rule documented in MODULE.md.
|
||||
de: |
|
||||
JSON-codiertes NormalizedNorm (Felder s. EN).
|
||||
eli ist in 0.1 synthetisch — Deutschland hat noch keinen offiziellen
|
||||
ELI-Registrar auf Bundesebene. Syntheseregel in MODULE.md.
|
||||
|
||||
# Permissions required.
|
||||
#
|
||||
# Pure in-WASM normalisation: quick-xml + sha2. No filesystem,
|
||||
# no network. The empty list makes this explicit; the hub enforces it.
|
||||
permissions: []
|
||||
|
||||
# MIME-type allow-list for the `content` input. Studio reads this
|
||||
# through ModuleInfoResponse.accepts_mime as the OS file-picker filter.
|
||||
accepts_mime:
|
||||
- application/xml
|
||||
- text/xml
|
||||
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "1.86"
|
||||
targets = ["wasm32-wasip2"]
|
||||
components = ["rustfmt", "clippy"]
|
||||
357
src/lib.rs
Normal file
357
src/lib.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! `text.akoma_normalize` module — normalise heterogeneous legal
|
||||
//! document sources to a structured representation aligned with
|
||||
//! Akoma Ntoso 1.0 (OASIS LegalDocML).
|
||||
//!
|
||||
//! # Capability
|
||||
//!
|
||||
//! - **Input:**
|
||||
//! * `content` — `bytes` payload of one legal-document file.
|
||||
//! * `mime` — optional `text` MIME hint. If omitted, the module
|
||||
//! sniffs leading bytes.
|
||||
//! - **Output:**
|
||||
//! * `norm` — `json` payload conforming to the `NormalizedNorm`
|
||||
//! schema below.
|
||||
//!
|
||||
//! # Adapter coverage (v0.1.0)
|
||||
//!
|
||||
//! Ships ONE adapter: gesetze-im-internet (BMJ) XML schema.
|
||||
//! HTML (landesrecht-berlin) and FORMEX (EUR-Lex) adapters follow
|
||||
//! in 0.2 / 0.3 without an interface break — the `adapter` field
|
||||
//! tags which adapter ran, so downstream consumers can route.
|
||||
//!
|
||||
//! # ELI synthesis (v0.1.0 caveat)
|
||||
//!
|
||||
//! Germany lacks an official Bundesebene ELI registrar. v0.1.0
|
||||
//! synthesises a deterministic ELI from the source metadata
|
||||
//! (`jurabk` + paragraph label), prefixed `eli/bund/` to mark it
|
||||
//! synthetic. Replace once BMJ publishes ELI URIs upstream.
|
||||
//!
|
||||
//! # Stability
|
||||
//!
|
||||
//! `0.1.0` ships as `alpha` in the store index. The output schema
|
||||
//! is intentionally forward-compatible: future adapters add
|
||||
//! fields, never rename or repurpose.
|
||||
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use chain_module_sdk::prelude::*;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const XML_MIME: &str = "application/xml";
|
||||
const XML_MIME_ALT: &str = "text/xml";
|
||||
|
||||
const ADAPTER_GII: &str = "gesetze-im-internet-xml";
|
||||
const ADAPTER_GII_VERSION: &str = "0.1.0";
|
||||
const SCHEMA_VERSION: &str = "0.1";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NormalizedNorm {
|
||||
schema_version: &'static str,
|
||||
adapter: &'static str,
|
||||
adapter_version: &'static str,
|
||||
eli: String,
|
||||
jurabk: Option<String>,
|
||||
paragraph: Option<String>,
|
||||
title: Option<String>,
|
||||
paragraphs: Vec<Paragraph>,
|
||||
source_sha256: String,
|
||||
normalized_sha256: String,
|
||||
/// Akoma Ntoso XML emission is reserved for 0.2+. v0.1 omits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
akoma_xml: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Paragraph {
|
||||
/// Absatz number as it appears in the source (e.g. "(1)", "(2)").
|
||||
/// Empty when the source provides no numbering.
|
||||
number: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[fai_module]
|
||||
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||
let bytes = inputs.require_bytes("content")?;
|
||||
let mime_hint_lower: Option<String> =
|
||||
inputs.get("mime").and_then(|p| match p {
|
||||
Payload::Text(s) => Some(s.to_ascii_lowercase()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let mime = mime_hint_lower
|
||||
.or_else(|| sniff_mime(&bytes.data))
|
||||
.unwrap_or_else(|| bytes.mime_type.clone());
|
||||
|
||||
let norm = match mime.as_str() {
|
||||
XML_MIME | XML_MIME_ALT => normalize_gii_xml(&bytes.data)?,
|
||||
other => {
|
||||
return Err(ModuleError::invalid_input(format!(
|
||||
"unsupported MIME in v0.1.0: {other} \
|
||||
(supported: {XML_MIME}, {XML_MIME_ALT}; \
|
||||
HTML/FORMEX adapters land in 0.2+)",
|
||||
)));
|
||||
}
|
||||
};
|
||||
Outputs::new().with_json("norm", &norm)
|
||||
}
|
||||
|
||||
/// Naive sniff: if the buffer starts with `<?xml` or `<dokumente`,
|
||||
/// we call it XML. Real adapter dispatch happens in `invoke` once
|
||||
/// MIME is known.
|
||||
fn sniff_mime(data: &[u8]) -> Option<String> {
|
||||
let head = data.get(..256.min(data.len()))?;
|
||||
let head_str = core::str::from_utf8(head).ok()?.trim_start();
|
||||
if head_str.starts_with("<?xml") || head_str.starts_with("<dokumente") {
|
||||
Some(XML_MIME.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// gesetze-im-internet (BMJ) XML schema adapter.
|
||||
///
|
||||
/// Extracts the well-known metadata keys (`jurabk`, `enbez`,
|
||||
/// `titel`) and joins the body text. Forward-compatible: unknown
|
||||
/// elements are silently skipped, so a schema extension upstream
|
||||
/// does not break this adapter.
|
||||
fn normalize_gii_xml(data: &[u8]) -> Result<NormalizedNorm, ModuleError> {
|
||||
let source_sha = hex_sha256(data);
|
||||
|
||||
let mut reader = Reader::from_reader(data);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut jurabk: Option<String> = None;
|
||||
let mut paragraph: Option<String> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut text_buffer = String::new();
|
||||
let mut in_text = false;
|
||||
let mut current_tag: Option<Vec<u8>> = None;
|
||||
let mut buf = Vec::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let name = e.name().as_ref().to_vec();
|
||||
if name == b"text" || name == b"textdaten" {
|
||||
in_text = true;
|
||||
}
|
||||
current_tag = Some(name);
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
let name_owned = e.name();
|
||||
let name = name_owned.as_ref();
|
||||
if name == b"text" || name == b"textdaten" {
|
||||
in_text = false;
|
||||
}
|
||||
current_tag = None;
|
||||
}
|
||||
Ok(Event::Text(t)) => {
|
||||
let text = t.unescape().map_err(xml_err)?.into_owned();
|
||||
if in_text {
|
||||
if !text_buffer.is_empty() {
|
||||
text_buffer.push('\n');
|
||||
}
|
||||
text_buffer.push_str(&text);
|
||||
} else if let Some(tag) = current_tag.as_deref() {
|
||||
match tag {
|
||||
b"jurabk" if jurabk.is_none() => jurabk = Some(text),
|
||||
b"enbez" if paragraph.is_none() => paragraph = Some(text),
|
||||
b"titel" if title.is_none() => title = Some(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(xml_err(e)),
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
let paragraphs = split_paragraphs(&text_buffer);
|
||||
let eli = synthesise_eli(jurabk.as_deref(), paragraph.as_deref());
|
||||
let normalized_payload = format!(
|
||||
"{}|{}|{}|{}",
|
||||
eli,
|
||||
jurabk.as_deref().unwrap_or(""),
|
||||
paragraph.as_deref().unwrap_or(""),
|
||||
text_buffer,
|
||||
);
|
||||
let normalized_sha = hex_sha256(normalized_payload.as_bytes());
|
||||
|
||||
Ok(NormalizedNorm {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
adapter: ADAPTER_GII,
|
||||
adapter_version: ADAPTER_GII_VERSION,
|
||||
eli,
|
||||
jurabk,
|
||||
paragraph,
|
||||
title,
|
||||
paragraphs,
|
||||
source_sha256: source_sha,
|
||||
normalized_sha256: normalized_sha,
|
||||
akoma_xml: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split the joined text into Absatz units when leading
|
||||
/// `(N)` markers are present. Sources without numbered Absatzes
|
||||
/// land as a single Paragraph with empty number.
|
||||
fn split_paragraphs(text: &str) -> Vec<Paragraph> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out: Vec<Paragraph> = Vec::new();
|
||||
let mut current = Paragraph { number: String::new(), text: String::new() };
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(num) = leading_paragraph_marker(line) {
|
||||
if !current.text.is_empty() {
|
||||
out.push(core::mem::replace(
|
||||
&mut current,
|
||||
Paragraph { number: String::new(), text: String::new() },
|
||||
));
|
||||
}
|
||||
current.number = num.to_string();
|
||||
let rest = line.get(num.len()..).unwrap_or("").trim_start();
|
||||
current.text.push_str(rest);
|
||||
} else {
|
||||
if !current.text.is_empty() {
|
||||
current.text.push('\n');
|
||||
}
|
||||
current.text.push_str(line);
|
||||
}
|
||||
}
|
||||
if !current.text.is_empty() || !current.number.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recognises `(1)`, `(2a)`, `(10)` at the start of a line.
|
||||
fn leading_paragraph_marker(line: &str) -> Option<&str> {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.first() != Some(&b'(') {
|
||||
return None;
|
||||
}
|
||||
let close = bytes.iter().position(|&b| b == b')')?;
|
||||
if close < 2 || close > 5 {
|
||||
return None;
|
||||
}
|
||||
let inner = line.get(1..close)?;
|
||||
if inner.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
line.get(..=close)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic ELI: `eli/bund/<jurabk-lower>/<paragraph-token>`.
|
||||
/// Lossy and deterministic. Replace when BMJ publishes upstream
|
||||
/// ELI URIs.
|
||||
fn synthesise_eli(jurabk: Option<&str>, paragraph: Option<&str>) -> String {
|
||||
let abk = jurabk.unwrap_or("unknown").trim().to_ascii_lowercase();
|
||||
let para = paragraph
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
if para.is_empty() {
|
||||
format!("eli/bund/{abk}")
|
||||
} else {
|
||||
format!("eli/bund/{abk}/{para}")
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_sha256(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let digest = hasher.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
let hi = byte >> 4;
|
||||
let lo = byte & 0x0f;
|
||||
s.push(nibble_to_hex(hi));
|
||||
s.push(nibble_to_hex(lo));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + n - 10) as char,
|
||||
_ => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_err<E: core::fmt::Display>(e: E) -> ModuleError {
|
||||
ModuleError::invalid_input(format!("XML parse failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_GEWO_14: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<dokumente>
|
||||
<norm doknr="BJNR9410000">
|
||||
<metadaten>
|
||||
<jurabk>GewO</jurabk>
|
||||
<enbez>§ 14</enbez>
|
||||
<titel>Anzeigepflicht</titel>
|
||||
</metadaten>
|
||||
<textdaten>
|
||||
<text>
|
||||
(1) Wer den selbstständigen Betrieb eines stehenden Gewerbes anfängt, muss dies der zuständigen Behörde gleichzeitig anzeigen.
|
||||
(2) Die Anzeige hat unverzüglich zu erfolgen.
|
||||
</text>
|
||||
</textdaten>
|
||||
</norm>
|
||||
</dokumente>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_gewo_14_metadata() {
|
||||
let norm = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
|
||||
assert_eq!(norm.jurabk.as_deref(), Some("GewO"));
|
||||
assert_eq!(norm.paragraph.as_deref(), Some("§ 14"));
|
||||
assert_eq!(norm.title.as_deref(), Some("Anzeigepflicht"));
|
||||
assert_eq!(norm.eli, "eli/bund/gewo/14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_into_absatz_paragraphs() {
|
||||
let norm = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
|
||||
assert_eq!(norm.paragraphs.len(), 2);
|
||||
assert_eq!(norm.paragraphs[0].number, "(1)");
|
||||
assert!(norm.paragraphs[0].text.starts_with("Wer den selbstständigen"));
|
||||
assert_eq!(norm.paragraphs[1].number, "(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_hash() {
|
||||
let a = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
|
||||
let b = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
|
||||
assert_eq!(a.source_sha256, b.source_sha256);
|
||||
assert_eq!(a.normalized_sha256, b.normalized_sha256);
|
||||
assert_eq!(a.source_sha256.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_xml_from_dokumente() {
|
||||
assert_eq!(
|
||||
sniff_mime(b"<?xml version=\"1.0\"?><dokumente>"),
|
||||
Some("application/xml".into())
|
||||
);
|
||||
assert_eq!(sniff_mime(b"<dokumente><norm/>"), Some("application/xml".into()));
|
||||
assert_eq!(sniff_mime(b"%PDF-1.4 ..."), None);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue