yep
This commit is contained in:
parent
6eb3097f3d
commit
9981647c5e
12 changed files with 1384 additions and 59 deletions
376
Cargo.lock
generated
376
Cargo.lock
generated
|
|
@ -2,33 +2,265 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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 = "anstream"
|
||||||
|
version = "0.6.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.100"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.5.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.5.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.5.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "classifier"
|
name = "classifier"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
|
||||||
|
|
||||||
|
[[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.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
|
|
@ -47,6 +279,20 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -90,6 +336,33 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.113"
|
version = "2.0.113"
|
||||||
|
|
@ -101,12 +374,115 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.10"
|
version = "1.0.10"
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,8 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.148"
|
serde_json = "1.0.148"
|
||||||
|
toml = "0.8"
|
||||||
|
|
|
||||||
BIN
docs/Facharbeit.pdf
Normal file
BIN
docs/Facharbeit.pdf
Normal file
Binary file not shown.
245
docs/Facharbeit.typ
Normal file
245
docs/Facharbeit.typ
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
#import "template.typ": conf
|
||||||
|
|
||||||
|
#show: conf.with(
|
||||||
|
title: [Kategorisierung und Tagging von \ Ressourcen mithilfe von LLMs],
|
||||||
|
authors: ("Nihad Badalov",),
|
||||||
|
teacher: "Michael Mayer",
|
||||||
|
due_date: "30.04.2026",
|
||||||
|
abstract: [
|
||||||
|
Im Zeitalter der Informationsüberflutung stellt die langfristige Organisation großer Mengen an Informationen wie Dokumenten, Lernmaterialien und Notizen eine zunehmende Herausforderung dar. Mit wachsendem Umfang werden solche Informationssammlungen häufig unübersichtlich, was den gezielten Zugriff erschwert. Eine verbreitete Methode zur Strukturierung ist das Tagging, bei dem Informationen mit Schlagwörtern zur verbesserten Kategorisierung und Auffindbarkeit versehen werden. Klassische, auf Machine Learning basierende Tagging-Ansätze erweisen sich dabei oft als komplex und sind auf umfangreiche annotierte Trainingsdaten angewiesen, um qualitativ hochwertige Ergebnisse zu erzielen. Vor diesem Hintergrund untersucht diese Arbeit den Einsatz großer Sprachmodelle (Large Language Models, LLMs) und strukturierter Modellausgaben als alternativen Ansatz zur automatisierten Informationsorganisation.
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
#let pipeline-box(body) = block(
|
||||||
|
width: 7.6cm,
|
||||||
|
inset: 8pt,
|
||||||
|
stroke: 0.8pt + black,
|
||||||
|
fill: luma(240),
|
||||||
|
)[
|
||||||
|
#align(center)[#body]
|
||||||
|
]
|
||||||
|
|
||||||
|
= Einleitung
|
||||||
|
|
||||||
|
== Motivation
|
||||||
|
|
||||||
|
Digitale Wissensarbeit ist heute durch eine hohe Verfügbarkeit und gleichzeitige Flüchtigkeit von Informationen geprägt. Fachartikel, Blogbeiträge, Dokumentationen, Social-Media-Beiträge und persönliche Notizen werden fortlaufend gesammelt, weiterverarbeitet und in unterschiedliche Werkzeuge übernommen. Mit wachsender Menge steigt jedoch nicht automatisch die Nutzbarkeit dieser Informationen. Ohne eine konsistente Struktur entstehen Sammlungen, in denen relevante Inhalte zwar vorhanden sind, später aber nur schwer wiedergefunden werden. Genau an dieser Stelle gewinnt die Verschlagwortung von Ressourcen an Bedeutung, weil sie Informationen nicht nur speichert, sondern auch inhaltlich erschließbar macht.
|
||||||
|
|
||||||
|
Im persönlichen wie im akademischen Kontext wird Tagging häufig dennoch manuell durchgeführt. Diese Vorgehensweise ist aufwendig, fehleranfällig und stark von der jeweiligen Person abhängig. Unterschiedliche Begriffe, wechselnde Granularität und uneinheitliche Benennungen führen dazu, dass ähnliche Ressourcen nicht unter denselben Schlagwörtern abgelegt werden. Besonders problematisch wird dies bei kurzen, kontextreichen Quellen wie Beiträgen auf X beziehungsweise Twitter, deren Aussage oft implizit bleibt und ohne inhaltliche Einordnung schwer interpretierbar ist. Die Motivation dieser Arbeit besteht deshalb darin, zu untersuchen, ob sich große Sprachmodelle für eine automatisierte, zugleich aber semantisch brauchbare Verschlagwortung solcher Ressourcen eignen.
|
||||||
|
|
||||||
|
== Problemstellung
|
||||||
|
|
||||||
|
Aus informationswissenschaftlicher Sicht handelt es sich bei der automatisierten Verschlagwortung um ein Klassifikationsproblem, das mehrere Schwierigkeiten gleichzeitig vereint. Erstens liegt häufig keine große, sauber annotierte Trainingsmenge vor. Zweitens sind die zu klassifizierenden Texte oft kurz, unvollständig oder sprachlich informell. Drittens sollen die vergebenen Tags nicht flach, sondern in einer hierarchischen Struktur organisiert werden, damit sowohl grobe als auch spezifische Themenbereiche abbildbar sind. Eine Ressource kann zudem mehreren Kategorien gleichzeitig zugeordnet werden, wodurch ein Multi-Label-Szenario entsteht @liu2023.
|
||||||
|
|
||||||
|
Klassische Verfahren des Machine Learning erzielen in klar umrissenen Anwendungsfällen zwar gute Ergebnisse, erfordern aber typischerweise eine vorgängige Merkmalsrepräsentation, Trainingsdaten und eine stabile Problemdefinition @manning2008. Im vorliegenden Projekt liegt die Herausforderung anders: Es existiert bereits ein hierarchischer Tag-Baum, doch es fehlt an umfangreich annotierten Beispielen für das Training eines speziellen Modells. Gleichzeitig soll das System in der Lage sein, auch für neue oder nur teilweise passende Inhalte sinnvolle Vorschläge zu machen. Daraus ergibt sich die zentrale Problemstellung, wie eine praktische, robuste und nachvollziehbare automatische Verschlagwortung unter realen Projektbedingungen umgesetzt werden kann.
|
||||||
|
|
||||||
|
== Zielsetzung und Forschungsfrage
|
||||||
|
|
||||||
|
Ziel dieser Facharbeit ist die theoretische Einordnung und praktische Umsetzung eines Prototyps zur automatisierten Kategorisierung digitaler Ressourcen mithilfe großer Sprachmodelle. Im Mittelpunkt steht nicht der Vergleich zahlreicher Modellvarianten, sondern die Entwicklung einer funktionsfähigen Verarbeitungspipeline, die Ressourcen aus einer Eingabeliste entgegennimmt, deren Inhalte extrahiert, einen strukturierten Klassifikationsaufruf ausführt und die Ergebnisse in einer Datenbank speichert. Der konkrete Prototyp dieses Projekts verarbeitet derzeit Beiträge von X beziehungsweise Twitter, ist in Rust implementiert und nutzt ein extern aufgerufenes LLM zur Zuordnung in eine vorgegebene Tag-Hierarchie.
|
||||||
|
|
||||||
|
Die leitende Forschungsfrage lautet daher: Inwieweit eignen sich Large Language Models dazu, kurze digitale Ressourcen ohne spezielles domänenspezifisches Training automatisch in ein hierarchisches Multi-Label-Tagging-System einzuordnen? Daraus ergeben sich zwei untergeordnete Fragen. Erstens ist zu klären, welche fachlichen Grenzen klassische Verfahren im gegebenen Szenario aufweisen. Zweitens soll untersucht werden, wie eine konkrete Implementierung beschaffen sein muss, damit die LLM-basierte Klassifikation technisch zuverlässig und praktisch nutzbar wird.
|
||||||
|
|
||||||
|
= Grundlagen
|
||||||
|
|
||||||
|
== Informationsretrieval und Metadaten
|
||||||
|
|
||||||
|
Informationsretrieval bezeichnet den Bereich der Informatik und Informationswissenschaft, der sich mit der Speicherung, Strukturierung, Suche und Wiederauffindung von Informationen beschäftigt. Anders als bei klassischen Datenbanksystemen stehen dabei häufig un- oder halbstrukturierte Inhalte im Mittelpunkt, etwa Texte, Webseiten oder Dokumente. Ziel ist es, aus einer größeren Informationsmenge diejenigen Elemente zu identifizieren, die für eine Anfrage oder einen Nutzungskontext relevant sind @manning2008. Für die Qualität eines Retrieval-Systems ist daher nicht allein entscheidend, ob Inhalte vorhanden sind, sondern ob sie in einer Form beschrieben werden, die ihre spätere Auffindbarkeit unterstützt.
|
||||||
|
|
||||||
|
Metadaten übernehmen in diesem Zusammenhang eine zentrale Rolle. Sie beschreiben Ressourcen durch zusätzliche Merkmale wie Titel, Autor, Thema, Entstehungszeitpunkt oder Schlagwörter. Solche beschreibenden Informationen schaffen eine zweite Ebene über dem eigentlichen Inhalt und ermöglichen es, Dokumente nicht nur wortwörtlich, sondern auch semantisch oder organisatorisch zu erschließen. In digitalen Wissenssammlungen sind Tags eine besonders flexible Form solcher Metadaten: Sie können thematische Zuordnungen herstellen, Querverbindungen sichtbar machen und den Übergang zwischen freier Sammlung und systematischer Ordnung erleichtern.
|
||||||
|
|
||||||
|
Gerade für heterogene Ressourcensammlungen sind Metadaten wichtig, weil Inhalte oft aus sehr unterschiedlichen Quellen stammen. Während ein wissenschaftlicher Artikel über umfangreiche bibliographische Angaben verfügt, enthält ein kurzer Social-Media-Beitrag zunächst nur wenige strukturierte Felder. Umso wichtiger ist es, aus den vorhandenen Informationen eine abstrahierte thematische Beschreibung abzuleiten. Die automatische Vergabe solcher Beschreibungen ist damit ein naheliegender Anwendungsfall des Informationsretrievals und zugleich eine Voraussetzung für spätere Such-, Filter- und Exportfunktionen.
|
||||||
|
|
||||||
|
== Tagging-Systeme und ihre Herausforderungen
|
||||||
|
|
||||||
|
Tagging-Systeme ordnen Ressourcen Schlagwörter zu, um sie thematisch zu gruppieren und leichter auffindbar zu machen. Im Unterschied zu streng kontrollierten Vokabularen oder Taxonomien sind Tags oft flexibel, offen und nutzergetrieben. Diese Offenheit ist praktisch, weil neue Begriffe schnell eingeführt werden können und Nutzerinnen und Nutzer ihre eigene Sicht auf Inhalte abbilden können. Gleichzeitig entstehen dadurch typische Probleme, etwa uneinheitliche Schreibweisen, Synonyme, Mehrdeutigkeiten und stark schwankende Detailgrade @guy2006.
|
||||||
|
|
||||||
|
Eine Ressource über Programmiersprachen könnte beispielsweise mit `rust`, `systems_programming`, `compiler`, `memory` oder `performance` markiert werden. Jede dieser Bezeichnungen ist potenziell sinnvoll, doch nicht jede repräsentiert dieselbe Ebene oder denselben Fokus. Ohne Strukturierung entstehen Sammlungen, in denen Tags nebeneinanderstehen, obwohl sie hierarchisch oder semantisch miteinander verbunden sind. Für Retrieval-Aufgaben ist das problematisch, weil die Suche entweder zu breit oder zu eng ausfallen kann.
|
||||||
|
|
||||||
|
Im vorliegenden Projekt wird deshalb kein flaches Tagging verwendet, sondern ein hierarchischer Tag-Baum. Dieser verbindet die Flexibilität einzelner Schlagwörter mit der Ordnungsfunktion einer Taxonomie. Ein Tag wie `cs/programming_languages/rust` ist aussagekräftiger als ein isoliertes `rust`, weil es den Begriff in einen größeren Kontext einordnet. Die Herausforderung verschiebt sich dadurch jedoch: Das System muss nicht nur ein passendes Thema erkennen, sondern auch die richtige Ebene innerhalb der Hierarchie wählen.
|
||||||
|
|
||||||
|
== Klassische Verfahren des Machine Learning
|
||||||
|
|
||||||
|
Vor dem Aufkommen großer Sprachmodelle wurde die automatische Textklassifikation überwiegend mit klassischen Verfahren des Machine Learning umgesetzt. Diese Methoden basieren meist auf einer expliziten Merkmalsdarstellung von Texten, etwa durch Worthäufigkeiten, und einem darauf trainierten Klassifikator. Sie sind rechnerisch oft effizient und in gut definierten Szenarien leistungsfähig. Ihr Erfolg hängt aber stark davon ab, wie gut Texte vorverarbeitet, Merkmale ausgewählt und Trainingsdaten gelabelt wurden @manning2008.
|
||||||
|
|
||||||
|
Für das Verständnis der späteren LLM-basierten Lösung ist ein kurzer Blick auf die zentralen klassischen Ansätze sinnvoll. Besonders relevant sind Bag-of-Words- und TF-IDF-Repräsentationen als Grundlage, Naive Bayes als probabilistisches Verfahren und Support Vector Machines als starke lineare Klassifikatoren für hochdimensionale Textdaten.
|
||||||
|
|
||||||
|
=== Bag-of-Words und TF-IDF
|
||||||
|
|
||||||
|
Beim Bag-of-Words-Ansatz wird ein Text als ungeordnete Menge von Wörtern modelliert. Entscheidend ist dabei nicht die genaue Wortreihenfolge, sondern welche Terme vorkommen und mit welcher Häufigkeit. Auf diese Weise lässt sich jeder Text in einen Vektor überführen, dessen Dimensionen einzelnen Wörtern oder Token entsprechen. Das Verfahren ist konzeptionell einfach und für viele frühe Textklassifikationssysteme grundlegend gewesen @manning2008.
|
||||||
|
|
||||||
|
Eine wichtige Verfeinerung stellt TF-IDF dar, also die Gewichtung durch Term Frequency und Inverse Document Frequency. Häufige Begriffe innerhalb eines Dokuments werden stärker gewichtet, während Wörter, die in nahezu allen Dokumenten vorkommen, an Bedeutung verlieren. Dadurch wird die Repräsentation informativer, weil charakteristische Begriffe stärker hervortreten. Salton und Buckley zeigen, dass unterschiedliche Gewichtungsschemata erheblichen Einfluss auf Retrieval- und Klassifikationsergebnisse haben können @salton1988.
|
||||||
|
|
||||||
|
Trotz ihres Nutzens bleibt die Repräsentation jedoch überwiegend oberflächenorientiert. Zwei inhaltlich ähnliche Texte können sehr unterschiedlich erscheinen, wenn sie unterschiedliche Vokabeln verwenden. Umgekehrt können Texte mit ähnlichen Begriffen thematisch verschieden sein. Genau diese Begrenzung wird bei kurzen und kontextabhängigen Ressourcen besonders sichtbar.
|
||||||
|
|
||||||
|
=== Naive Bayes
|
||||||
|
|
||||||
|
Naive Bayes ist ein probabilistisches Klassifikationsverfahren, das auf dem Satz von Bayes beruht und die Merkmale eines Dokuments als bedingt unabhängig voneinander annimmt. Diese Annahme ist in realen Texten zwar stark vereinfacht, führt in der Praxis jedoch häufig zu brauchbaren und robusten Ergebnissen. Vor allem in frühen Anwendungen der Dokumentklassifikation war Naive Bayes wegen seiner Einfachheit und Effizienz weit verbreitet @manning2008.
|
||||||
|
|
||||||
|
Für Textdaten existieren unterschiedliche Ereignismodelle, insbesondere multinomiale und Bernoulli-Varianten. McCallum und Nigam zeigen, dass die Wahl des Ereignismodells die Klassifikationsleistung deutlich beeinflussen kann und dokumentieren die Eignung von Naive Bayes für Textklassifikation trotz der starken Unabhängigkeitsannahme @mccallum1998. Die Stärke des Verfahrens liegt darin, mit vergleichsweise wenig Rechenaufwand Wahrscheinlichkeiten für Klassen zu schätzen.
|
||||||
|
|
||||||
|
Für komplexe Tagging-Systeme stößt Naive Bayes jedoch an Grenzen. Die Methode modelliert keine tieferen semantischen Beziehungen zwischen Begriffen und bildet Kontext nur indirekt über Worthäufigkeiten ab. Gerade bei mehrdeutigen oder sehr kurzen Texten kann das zu unscharfen Zuordnungen führen.
|
||||||
|
|
||||||
|
=== Support Vector Machines
|
||||||
|
|
||||||
|
Support Vector Machines, kurz SVM, gehören zu den klassischen Verfahren, die für Textklassifikation lange als besonders leistungsfähig galten. Ihr Grundprinzip besteht darin, im Merkmalsraum eine trennende Hyperebene zu finden, die Klassen möglichst gut voneinander separiert. Gerade in hochdimensionalen, spärlich besetzten Textvektoren funktioniert dieser Ansatz oft zuverlässig, weil viele Dokumente trotz großer Vokabularräume linear trennbar oder nahezu trennbar sind @joachims2002.
|
||||||
|
|
||||||
|
Für Textklassifikation ist die Methode deshalb attraktiv, weil sie mit TF-IDF- oder ähnlichen Repräsentationen gut kombinierbar ist. Joachims zeigt, dass SVMs in diesem Bereich hohe Genauigkeit erreichen und insbesondere bei großen Merkmalsräumen robust arbeiten @joachims2002. Im Vergleich zu einfacheren Verfahren wie Naive Bayes ist das Modell jedoch schwerer interpretierbar und erfordert ebenfalls annotierte Trainingsdaten.
|
||||||
|
|
||||||
|
Auch SVMs lösen nicht das Grundproblem fehlender Semantik. Sie trennen Klassen auf Basis der vorgegebenen Merkmalsdarstellung, erzeugen diese Darstellung aber nicht selbst. Wenn relevante Inhalte in einem Text implizit bleiben oder nur durch Weltwissen erschließbar sind, kann auch ein starker Klassifikator nur begrenzt helfen.
|
||||||
|
|
||||||
|
== Grenzen klassischer Ansätze
|
||||||
|
|
||||||
|
Die beschriebenen Verfahren haben wesentlich zur Entwicklung moderner Textklassifikation beigetragen, doch ihre Grenzen werden im Kontext dieser Arbeit deutlich. Erstens setzen sie in der Regel ein gelabeltes Korpus voraus. Für ein persönliches oder projektbezogenes Tagging-System liegt ein solches Korpus häufig nicht vor, und seine Erstellung wäre zeitaufwendig. Zweitens hängen klassische Verfahren stark von der Qualität der Merkmalsrepräsentation ab. Werden Synonyme, Mehrdeutigkeiten oder thematische Beziehungen nicht bereits in den Merkmalen erfasst, kann das Modell sie kaum nachträglich rekonstruieren @manning2008.
|
||||||
|
|
||||||
|
Hinzu kommt, dass kurze Social-Media-Texte oft nur bruchstückhafte Informationen enthalten. Ein Beitrag kann auf eine Debatte verweisen, implizites Fachwissen voraussetzen oder wesentliche Teile seiner Bedeutung aus Links, Autorenschaft und Diskurskontext beziehen. Ein Bag-of-Words-Modell sieht in einem solchen Fall nur wenige Terme. Für die Auswahl eines spezifischen Tags innerhalb einer Hierarchie ist dies oft nicht ausreichend.
|
||||||
|
|
||||||
|
Schließlich ist das Ziel dieses Projekts nicht die Vorhersage genau einer Klasse, sondern die Zuweisung mehrerer möglichst spezifischer Tags in einer hierarchischen Struktur. Klassische Modelle können zwar für Multi-Label- oder Hierarchieaufgaben erweitert werden, doch dies erhöht Modellierungsaufwand und Komplexität. Für ein prototypisches System ohne große Trainingsdatenbasis liegt daher ein anderer Ansatz näher: die Nutzung eines bereits breit vortrainierten Sprachmodells.
|
||||||
|
|
||||||
|
== Large Language Models
|
||||||
|
|
||||||
|
=== Grundprinzipien und Training
|
||||||
|
|
||||||
|
Large Language Models sind neuronale Sprachmodelle, die auf sehr großen Textmengen vortrainiert werden und auf dieser Grundlage statistische Regularitäten von Sprache, Wissen und typischen Textmustern erfassen. Im Kern lernen sie, welches Token mit welcher Wahrscheinlichkeit auf einen gegebenen Kontext folgt. Durch die enorme Menge an Trainingsdaten und Parametern entstehen Modelle, die nicht nur lokale Wortfolgen, sondern auch komplexere sprachliche und semantische Zusammenhänge verarbeiten können @bommasani2021.
|
||||||
|
|
||||||
|
Der entscheidende Unterschied zu klassischen Textklassifikationsverfahren liegt darin, dass LLMs nicht speziell für eine einzelne Klassifikationsaufgabe trainiert werden müssen, um nützliche Ergebnisse zu liefern. Vielmehr können sie durch Anweisungen im Prompt auf neue Aufgaben ausgerichtet werden. Diese Fähigkeit macht sie insbesondere für Szenarien interessant, in denen keine große, sauber annotierte Trainingsmenge vorhanden ist, aber dennoch eine inhaltlich differenzierte Beurteilung nötig ist.
|
||||||
|
|
||||||
|
=== Transformer-Architektur (konzeptionell)
|
||||||
|
|
||||||
|
Die gegenwärtige Leistungsfähigkeit großer Sprachmodelle ist eng mit der Transformer-Architektur verbunden. Vaswani et al. führen mit dem Transformer ein Modell ein, das auf Self-Attention basiert und dadurch Abhängigkeiten zwischen Wörtern eines Textes parallel und kontextsensitiv verarbeiten kann @vaswani2017. Anstatt Wörter nur sequenziell zu betrachten, gewichtet das Modell, welche Teile des Eingabetextes für die Interpretation eines bestimmten Tokens besonders relevant sind.
|
||||||
|
|
||||||
|
Konzeptionell ist diese Architektur für Textklassifikation deshalb bedeutsam, weil sie Beziehungen über größere Distanzen hinweg erfassen kann. Wenn in einem Text erst spät klar wird, worauf sich ein Begriff bezieht, kann ein Transformer diese Information besser einbeziehen als klassische Modelle mit starren Vektorrepräsentationen. Für die hier betrachtete Aufgabe bedeutet das, dass nicht nur einzelne Schlüsselwörter, sondern auch ihre Einbettung in den Gesamtzusammenhang berücksichtigt werden können.
|
||||||
|
|
||||||
|
=== Semantisches Verständnis und Kontext
|
||||||
|
|
||||||
|
Im Zusammenhang mit LLMs wird häufig von semantischem Verständnis gesprochen. Fachlich präziser ist es, von kontextsensitiver Bedeutungsverarbeitung zu sprechen. Das Modell besitzt kein menschliches Verstehen, kann aber auf Basis seines Trainings sehr viele sprachliche Muster, thematische Bezüge und typische Diskurszusammenhänge in seine Vorhersagen einfließen lassen @bommasani2021. Für praktische Aufgaben wie Tagging ist genau diese Fähigkeit entscheidend, weil sie über rein oberflächenbasierte Schlüsselworterkennung hinausgeht.
|
||||||
|
|
||||||
|
Ein kurzer Text über Compilerbau kann beispielsweise Begriffe wie `intermediate representation`, `optimization passes` oder `toolchain` enthalten, ohne das Oberthema explizit zu benennen. Ein LLM kann solche Signale in Beziehung setzen und daraus eine spezifische thematische Einordnung ableiten. Gerade in technischen Wissenssammlungen ist diese Eigenschaft wertvoll, weil viele Ressourcen nur für Personen mit Vorwissen unmittelbar eindeutig sind.
|
||||||
|
|
||||||
|
=== Zero-Shot- und Few-Shot-Lernen
|
||||||
|
|
||||||
|
Ein weiterer wichtiger Aspekt ist die Fähigkeit großer Sprachmodelle zum Zero-Shot- und Few-Shot-Lernen. Brown et al. zeigen am Beispiel von GPT-3, dass große Sprachmodelle neue Aufgaben allein auf Basis sprachlicher Instruktionen und weniger oder sogar ganz ohne Beispiele bearbeiten können @brown2020. Das Modell muss dabei nicht durch Gradientenupdates auf die konkrete Aufgabe nachtrainiert werden, sondern reagiert direkt auf die Formulierung des Prompts.
|
||||||
|
|
||||||
|
Für das vorliegende Projekt ist insbesondere Zero-Shot-Lernen relevant. Die Klassifikation erfolgt nicht mithilfe eines separat trainierten Tagging-Modells, sondern durch einen Prompt, der die Tag-Hierarchie, die Ressource und das gewünschte Ausgabeformat enthält. Wenige oder gar keine Beispiele im Prompt senken den Vorbereitungsaufwand und machen das System flexibel. Gleichzeitig steigt damit die Bedeutung einer präzisen Aufgabenbeschreibung, weil die Qualität der Ausgabe stark von der Formulierung des Prompts abhängt.
|
||||||
|
|
||||||
|
= Methodik
|
||||||
|
|
||||||
|
== Problemformulierung der automatisierten Verschlagwortung
|
||||||
|
|
||||||
|
Methodisch lässt sich das in dieser Arbeit behandelte Problem als hierarchische Multi-Label-Textklassifikation formulieren. Gegeben ist eine digitale Ressource, im aktuellen Prototyp repräsentiert durch eine URL zu einem X-Beitrag sowie den daraus extrahierten Textinhalt. Gesucht ist eine Menge von einem bis drei möglichst spezifischen Tags aus einem vorgegebenen Tag-Baum. Die Tags sollen thematisch passen, hierarchisch korrekt eingeordnet sein und die Ressource so beschreiben, dass sie später wiedergefunden und in Beziehung zu anderen Ressourcen gesetzt werden kann.
|
||||||
|
|
||||||
|
Im Unterschied zu einer einfachen Ein-Klassen-Klassifikation müssen mehrere Bedingungen gleichzeitig erfüllt werden. Eine Ressource kann mehreren Themenbereichen zugeordnet sein, etwa `distributed_systems` und `databases`. Gleichzeitig soll das System eher spezifische Blattknoten als sehr allgemeine Oberkategorien wählen. Wenn kein passender Tag existiert, soll das System außerdem einen begründeten Vorschlag für einen neuen Tag liefern. Die Methodik umfasst somit nicht nur Zuordnung, sondern auch kontrollierte Erweiterbarkeit der Tag-Struktur.
|
||||||
|
|
||||||
|
== Ansatz mit Large Language Models
|
||||||
|
|
||||||
|
Der gewählte Ansatz nutzt ein Large Language Model als generischen Klassifikator, der ohne projektspezifisches Training auskommt. Statt ein Modell mit annotierten Beispielen auf den vorhandenen Tag-Baum zu trainieren, wird die Aufgabe zur Laufzeit in natürlicher Sprache formuliert. Das Modell erhält die aktuelle Tag-Hierarchie, die textuelle Beschreibung der Ressource und klare Regeln zur Ausgabe. Aus methodischer Sicht handelt es sich damit um eine instruktionale Zero-Shot-Klassifikation.
|
||||||
|
|
||||||
|
Dieser Ansatz ist für das Projekt aus drei Gründen sinnvoll. Erstens entfällt die aufwendige Erstellung eines Trainingsdatensatzes. Zweitens kann das Modell Weltwissen und semantische Relationen nutzen, um auch kurze oder indirekte Texte einzuordnen. Drittens lässt sich das System leicht anpassen, indem der Tag-Baum oder die Prompt-Regeln verändert werden, ohne dass ein neues Modell trainiert werden muss. Die methodische Flexibilität ist damit höher als bei einem klassisch trainierten Spezialmodell.
|
||||||
|
|
||||||
|
== Prompt-basierte Klassifikation
|
||||||
|
|
||||||
|
Die Klassifikation wird durch einen strukturierten Prompt gesteuert, der dem Sprachmodell die Aufgabe explizit vorgibt. Im Projekt enthält dieser Prompt erstens eine Rollenbeschreibung als Resource Classifier, zweitens Regeln zur Auswahl möglichst spezifischer Tags, drittens die aktuelle Tag-Hierarchie und viertens eine formatierte Ressourcendarstellung. Zusätzlich wird ein JSON-Ausgabeformat vorgegeben, das die Felder `tags`, `confidence`, `new_tags` und `reasoning` enthält. Der Prompt reduziert damit die Offenheit der Modellantwort und fördert eine maschinenlesbare Ausgabe.
|
||||||
|
|
||||||
|
Methodisch ist diese Form der Prompt-Gestaltung zentral, weil LLMs zwar flexibel, aber nicht deterministisch sind. Ein ungenauer Prompt könnte zu freien Textantworten, zu allgemeinen Kategorisierungen oder zu uneinheitlichen Formaten führen. Deshalb wird die Ausgabe auf ein klar beschriebenes JSON-Schema beschränkt. Die spätere Verarbeitung im Programm setzt nämlich voraus, dass die Modellantwort von `serde` geparst werden kann. Die Prompt-basierte Klassifikation ist in diesem Projekt somit nicht nur eine sprachliche Instruktion, sondern Teil des technischen Schnittstellendesigns.
|
||||||
|
|
||||||
|
== Mehrfachklassifikation (Multi-Label-Tagging)
|
||||||
|
|
||||||
|
Ein zentrales methodisches Merkmal des Systems ist die Mehrfachklassifikation. Viele technische Ressourcen behandeln mehrere Themen gleichzeitig. Ein Beitrag über eine Datenbank-Engine kann sich zugleich auf Architekturentscheidungen, Persistenz, Performance und Nebenläufigkeit beziehen. Eine Ein-Klassen-Entscheidung würde diese Mehrdimensionalität unzulässig reduzieren. Das Modell wird daher angewiesen, ein bis drei passende Tags zurückzugeben.
|
||||||
|
|
||||||
|
Damit die Mehrfachklassifikation nicht zu unspezifischen Sammellisten führt, werden pro Tag zusätzlich Konfidenzwerte erwartet. Diese Werte sind im Prototyp keine mathematisch kalibrierten Wahrscheinlichkeiten, sondern modellgenerierte Einschätzungen, die als heuristische Stärke der Zuordnung interpretiert werden. In der Anwendung dienen sie dazu, besonders unsichere Fälle zu markieren. Zusätzlich sieht das Schema vor, neue Tags mit Elternkategorie und Begründung vorzuschlagen, wenn der bestehende Baum keine zufriedenstellende Zuordnung erlaubt.
|
||||||
|
|
||||||
|
== Evaluationskriterien
|
||||||
|
|
||||||
|
Auch wenn in dieser Fassung noch keine systematische Ergebnisdiskussion vorgenommen wird, lassen sich klare Evaluationskriterien formulieren. Ein erstes Kriterium ist die inhaltliche Angemessenheit: Die vergebenen Tags sollen den Gegenstand einer Ressource korrekt und nicht nur oberflächlich beschreiben. Ein zweites Kriterium ist die Spezifität. Ein System, das systematisch nur allgemeine Oberkategorien wie `cs` oder `software_development` auswählt, wäre praktisch wenig nützlich, selbst wenn die Zuordnungen formal nicht falsch wären.
|
||||||
|
|
||||||
|
Ein drittes Kriterium betrifft die hierarchische Konsistenz. Die vorgeschlagenen Tags müssen in die bestehende Struktur passen und dürfen keine semantisch widersprüchlichen Kombinationen erzeugen. Viertens ist die Robustheit der Ausgabe wichtig: Für die technische Nutzbarkeit muss die Antwort zuverlässig im erwarteten JSON-Format vorliegen. Fünftens ist die praktische Anschlussfähigkeit zu nennen. Ein brauchbares System muss seine Ergebnisse speichern, exportieren und bei wiederholter Verarbeitung konsistent behandeln können. Diese Kriterien verbinden damit semantische, strukturelle und technische Qualitätsanforderungen.
|
||||||
|
|
||||||
|
= Umsetzung
|
||||||
|
|
||||||
|
== Datenerhebung
|
||||||
|
|
||||||
|
=== Datenquelle (Twitter)
|
||||||
|
|
||||||
|
Die Datenerhebung des vorliegenden Prototyps basiert auf einer einfachen, kontrollierbaren Eingabequelle: einer Datei mit URLs namens `test-classification-list`. Jede Zeile dieser Datei enthält eine Ressource, die klassifiziert werden soll. Im aktuellen Entwicklungsstand werden ausschließlich URLs von X beziehungsweise Twitter verarbeitet. Die Entscheidung für diese Datenquelle ist pragmatisch begründet. Beiträge auf X sind leicht verlinkbar, technisch klar identifizierbar und zugleich inhaltlich anspruchsvoll, weil sie kurz, kontextreich und oft stark verdichtet formuliert sind.
|
||||||
|
|
||||||
|
Die Wahl dieser Quelle passt zum Ziel der Arbeit, ein praxisnahes System für persönliche Wissenssammlungen zu untersuchen. Viele technisch orientierte Beiträge, Diskussionen oder Hinweise auf Werkzeuge und Fachthemen werden heute in Social-Media-Form verbreitet. Solche Ressourcen gehen ohne systematische Organisation leicht verloren. Der Prototyp nutzt X-Beiträge daher als realistische Testumgebung für ein später erweiterbares Klassifikationssystem.
|
||||||
|
|
||||||
|
=== Eigenschaften und Herausforderungen der Daten
|
||||||
|
|
||||||
|
Die verwendeten Daten weisen mehrere Eigenschaften auf, die ihre automatische Verarbeitung erschweren. Zunächst sind die Texte kurz. Anders als wissenschaftliche Artikel oder längere Blogbeiträge enthalten Tweets meist nur wenige Sätze. Relevante Informationen bleiben häufig implizit oder sind auf externe Links ausgelagert. Zusätzlich wird in Social-Media-Beiträgen oft informelle Sprache verwendet, einschließlich Abkürzungen, Fachjargon, Ironie oder verkürzter Formulierungen.
|
||||||
|
|
||||||
|
Hinzu kommen strukturelle Herausforderungen. Die Eingabeliste kann Duplikate enthalten, was im Testdatensatz tatsächlich vorkommt. Außerdem ist ein Beitrag nicht nur durch seinen Text charakterisiert, sondern auch durch Metadaten wie Autor, Beitrag-ID und eingebettete URLs. Der Prototyp verarbeitet daher nicht allein den reinen Text, sondern verbindet ihn mit einer knappen kontextgebenden Titelform. Aus methodischer Sicht wird dadurch versucht, den Informationsverlust kurzer Texte teilweise auszugleichen, ohne den Aufwand eines vollständigen Diskurskontextes übernehmen zu müssen.
|
||||||
|
|
||||||
|
== Datenverarbeitungspipeline
|
||||||
|
|
||||||
|
Die Verarbeitung der Ressourcen erfolgt in einer mehrstufigen Pipeline, die in [@fig:pipeline] schematisch dargestellt ist. Ausgangspunkt ist eine Liste von URLs. Nach der Quelle-Erkennung wird der jeweilige Beitrag extrahiert, in eine einheitliche Textrepräsentation überführt, durch das Sprachmodell klassifiziert und schließlich zusammen mit seinen Tag-Zuordnungen in einer SQLite-Datenbank gespeichert. Zusätzlich unterstützt das System einen Export in JSON sowie eine einfache Statistikabfrage über den aktuellen Datenbestand.
|
||||||
|
|
||||||
|
#figure(
|
||||||
|
[
|
||||||
|
#align(center)[
|
||||||
|
#pipeline-box([`test-classification-list` \ URL-Liste])
|
||||||
|
#v(0.15cm)
|
||||||
|
[↓]
|
||||||
|
#v(0.15cm)
|
||||||
|
#pipeline-box([Python-Scraper \ `scrape_user_tweet_contents.py`])
|
||||||
|
#v(0.15cm)
|
||||||
|
[↓]
|
||||||
|
#v(0.15cm)
|
||||||
|
#pipeline-box([TOML-Parsing \ und Normalisierung])
|
||||||
|
#v(0.15cm)
|
||||||
|
[↓]
|
||||||
|
#v(0.15cm)
|
||||||
|
#pipeline-box([LLM-Klassifikation \ `codex e` mit JSON-Ausgabe])
|
||||||
|
#v(0.15cm)
|
||||||
|
[↓]
|
||||||
|
#v(0.15cm)
|
||||||
|
#pipeline-box([SQLite-Speicherung \ sowie Export und Statistik])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
caption: [Vereinfachte Datenverarbeitungspipeline des implementierten Prototyps.]
|
||||||
|
) <fig:pipeline>
|
||||||
|
|
||||||
|
=== Extraktion
|
||||||
|
|
||||||
|
Die eigentliche Verarbeitung beginnt in `src/main.rs`. Dort wird zunächst die Eingabedatei eingelesen und zeilenweise verarbeitet. Für jede URL bestimmt die Funktion `determine_resource_source`, ob es sich um eine unterstützte Quelle handelt. Gegenwärtig werden nur Adressen erkannt, die `twitter.com` oder `x.com` enthalten. Alle anderen Quellen werden mit einem Hinweis übersprungen. Diese Beschränkung macht deutlich, dass der Prototyp noch kein allgemeiner Web-Scraper ist, sondern zunächst einen klar abgegrenzten Ressourcentyp implementiert.
|
||||||
|
|
||||||
|
Für Twitter- beziehungsweise X-Links ruft das Programm die Funktion `scrapers::twitter::scrape(url)` auf. Diese extrahiert aus der URL die Tweet-ID und startet anschließend über `Command::new("python")` das externe Skript `scrape_user_tweet_contents.py`. Wenn die Extraktion erfolgreich war, liegt im Verzeichnis `scraped-tweets/` eine TOML-Datei mit dem Namen `tweet-<id>.toml` vor. Diese Datei dient als Übergabeformat zwischen Scraper und weiterer Rust-Verarbeitung. Die Extraktion ist damit technisch ausgelagert, aber sauber in die Hauptpipeline integriert.
|
||||||
|
|
||||||
|
=== Vorverarbeitung
|
||||||
|
|
||||||
|
Nach der Extraktion wird die erzeugte TOML-Datei mit `parse_scraped_tweet` eingelesen. Die Implementierung versucht zunächst, das erwartete Format mittels `serde` in eine interne Struktur zu deserialisieren. Dabei werden Felder wie `id`, `full_text` und der Autorenname unter `author.screen_name` berücksichtigt. Falls diese direkte Deserialisierung scheitert, greift eine Fallback-Routine, die relevante Zeilen manuell aus dem TOML-Text extrahiert. Diese zweistufige Verarbeitung erhöht die Robustheit gegenüber kleineren Formatabweichungen der Scraper-Ausgabe.
|
||||||
|
|
||||||
|
Das Ergebnis der Vorverarbeitung ist ein vereinheitlichtes Objekt vom Typ `ScrapedTweet`, das die drei wesentlichen Informationen `id`, `text` und `author` enthält. Für die Klassifikation wird daraus ein formatierter String erzeugt: `Title: Tweet by @...` und `Content: ...`. Diese Darstellung ist bewusst einfach gehalten. Sie liefert dem Sprachmodell sowohl den eigentlichen Inhalt als auch einen minimalen Kontext über die Autorschaft, ohne zusätzliche Komplexität einzuführen. In methodischer Hinsicht ist die Vorverarbeitung damit kein schweres Text-Cleaning, sondern eine zielgerichtete Normalisierung in eine LLM-freundliche Form.
|
||||||
|
|
||||||
|
=== Klassifikation
|
||||||
|
|
||||||
|
Die Klassifikation nutzt den in der Datei `tag-tree` abgelegten hierarchischen Tag-Baum als formale Zielstruktur. Dieser Baum wird zu Beginn des Programms eingelesen und anschließend an den Klassifikationsprompt übergeben. Die Funktion `classify_with_retry` in `src/classifiers.rs` übernimmt die Orchestrierung des Klassifikationsaufrufs. Intern ruft sie `classify` auf, welches über ein Subprozesskommando `codex e` mit dem konstruierten Prompt startet. Damit ist das LLM nicht direkt als Bibliothek eingebunden, sondern über eine externe Prozessschnittstelle angebunden.
|
||||||
|
|
||||||
|
Die Modellantwort soll ausschließlich JSON enthalten. Dieses JSON wird in die Struktur `ClassificationResult` geparst, die die Felder `tags`, `confidence`, `new_tags` und `reasoning` umfasst. Um mit unvollständigen Antworten umgehen zu können, sind die Felder mit `#[serde(default)]` versehen. Zusätzlich enthält die Funktion eine Retry-Logik: Scheitert entweder der Modellaufruf oder das Parsen der JSON-Antwort, wird der Vorgang bis zur maximalen Versuchszahl wiederholt. Diese Fehlerbehandlung ist für die Praxis wesentlich, weil freie Sprachmodelle zwar strukturierte Ausgaben erzeugen können, ihre Formatdisziplin aber nicht absolut garantiert ist.
|
||||||
|
|
||||||
|
=== Speicherung der Tags
|
||||||
|
|
||||||
|
Bereits beim Programmstart wird eine SQLite-Datenbank unter dem Pfad `resources.db` geöffnet und über `init_schema()` mit den benötigten Tabellen initialisiert. Das Datenbankschema umfasst die Tabellen `resources`, `tags`, `resource_tags` und `classification_log`. Dadurch werden sowohl die eigentlichen Ressourcen als auch ihre Zuordnungen, Konfidenzwerte und Begründungen der Klassifikation gespeichert. Die Wahl von SQLite ist für einen lokalen Prototyp zweckmäßig, weil keine separate Server-Infrastruktur nötig ist und relationale Tabellen dennoch zuverlässig angelegt werden können @sqlite2026.
|
||||||
|
|
||||||
|
Vor einer neuen Verarbeitung prüft `resource_exists(url)`, ob die betreffende URL bereits bekannt ist. So kann verhindert werden, dass identische Ressourcen bei wiederholten Läufen erneut klassifiziert werden, sofern nicht der `--force`-Schalter gesetzt ist. Nach erfolgreicher Klassifikation wird die Ressource zunächst über `insert_resource` in die Tabelle `resources` eingetragen oder aktualisiert. Anschließend erzeugt `store_classification` die Tag-Zuordnungen in `resource_tags`, legt fehlende Hierarchieeinträge in `tags` an und schreibt die Begründung sowie Tag-Vorschläge in `classification_log`. Die Speicherung ist damit nicht nur Ergebnisablage, sondern strukturelle Grundlage für spätere Auswertung, Export und Weiterverarbeitung.
|
||||||
|
|
||||||
|
== Implementierungsdetails
|
||||||
|
|
||||||
|
=== Projektstruktur
|
||||||
|
|
||||||
|
Das Projekt ist bewusst modular gehalten. Die zentrale Ablaufsteuerung liegt in `src/main.rs`, wo die Kommandozeilenschnittstelle mit `clap` definiert wird. Dort werden die drei Subkommandos `classify`, `export` und `stats` bereitgestellt. `classify` verarbeitet Ressourcen aus einer Eingabedatei, `export` schreibt gespeicherte Ressourcen in eine JSON-Datei und `stats` gibt eine einfache Übersicht über Ressourcen- und Tag-Anzahlen aus. Diese Aufteilung macht die Anwendung als Werkzeug bedienbar und trennt Steuerlogik von Fachlogik.
|
||||||
|
|
||||||
|
Die Klassifikationslogik selbst liegt in `src/classifiers.rs`, die Datenbankanbindung in `src/db.rs` und die Scraper-spezifische Verarbeitung in `src/scrapers/twitter.rs`. Ergänzt wird diese Struktur durch externe Projektdateien wie `tag-tree`, `test-classification-list` und das Verzeichnis `scraped-tweets/`. Die Architektur folgt damit einer klaren Rollenverteilung: Eingabe und Ablaufsteuerung, Datenextraktion, semantische Klassifikation sowie Persistenz sind voneinander getrennt, aber über einfache Funktionsschnittstellen gekoppelt.
|
||||||
|
|
||||||
|
=== Klassifikationslogik
|
||||||
|
|
||||||
|
Die interne Klassifikationslogik des Programms ist auf idempotente und nachvollziehbare Verarbeitung ausgelegt. Für jede URL wird zunächst geprüft, ob sie bereits in der Datenbank vorhanden ist. Existiert ein Eintrag und wurde kein `--force`-Flag gesetzt, wird die Ressource übersprungen. Damit bleibt der Lauf effizient und wiederholbar. Wird `--force` verwendet, wird eine bestehende Ressource erneut klassifiziert, was insbesondere bei verändertem Prompt oder erweitertem Tag-Baum sinnvoll ist.
|
||||||
|
|
||||||
|
Nach dem eigentlichen Modellaufruf werden die vom LLM gelieferten Informationen nicht blind übernommen, sondern zusätzlich ausgewertet. Das Programm gibt gefundene Tags, Konfidenzen und die textuelle Begründung auf der Konsole aus. Für neue Tag-Vorschläge wird der Elternknoten samt Begründung gesondert angezeigt. Außerdem wird mit `confident_tags(0.5)` eine einfache Schwelle angewandt, um sehr unsichere Klassifikationen sichtbar zu machen. Diese Schwelle ersetzt keine wissenschaftliche Evaluation, zeigt aber, wie modellinterne Sicherheitsschätzungen bereits im Prototyp in praktische Entscheidungslogik überführt werden können.
|
||||||
|
|
||||||
|
=== Scraper-Implementierung
|
||||||
|
|
||||||
|
Die Scraper-Implementierung ist ein gutes Beispiel für die pragmatische, prototypische Natur des Projekts. Anstatt eine vollständige Scraper-Logik direkt in Rust nachzubauen, nutzt das System ein bereits vorhandenes Python-Skript zur Extraktion von Tweet-Inhalten. Die Rust-Seite übernimmt die Rolle eines Wrappers: Sie extrahiert die Tweet-ID aus der URL, ruft das Skript mit dieser ID auf und prüft anschließend, ob die erwartete TOML-Datei erzeugt wurde. Dieser Ansatz beschleunigt die Entwicklung, weil vorhandene Werkzeuge wiederverwendet werden können.
|
||||||
|
|
||||||
|
Gleichzeitig zeigt die Implementierung, an welchen Stellen praktische Robustheit wichtig wird. Die durch den Scraper erzeugten TOML-Dateien folgen zwar einem erwarteten Grundschema, enthalten aber verschachtelte Felder und potenzielle Formatvarianten. Deshalb kombiniert der Parser eine reguläre `serde`-Deserialisierung mit einer manuellen Fallback-Auswertung einzelner Schlüssel wie `id`, `full_text` und `screen_name`. Für den Zweck des Prototyps ist dies ein sinnvoller Kompromiss zwischen Eleganz und Fehlertoleranz. Perspektivisch wäre denkbar, die Datenextraktion noch stärker zu standardisieren oder auf weitere Ressourcentypen auszudehnen. In der aktuellen Projektphase ist die bestehende Lösung jedoch ausreichend, um eine durchgängige Klassifikationspipeline realistisch zu demonstrieren.
|
||||||
|
|
||||||
|
#bibliography("refs.bib", style: "ieee", title: [Literaturverzeichnis])
|
||||||
86
docs/refs.bib
Normal file
86
docs/refs.bib
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
@book{manning2008,
|
||||||
|
author = {Christopher D. Manning and Prabhakar Raghavan and Hinrich Schütze},
|
||||||
|
title = {Introduction to Information Retrieval},
|
||||||
|
publisher = {Cambridge University Press},
|
||||||
|
year = {2008},
|
||||||
|
doi = {10.1017/CBO9780511809071},
|
||||||
|
isbn = {9780511809071}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{salton1988,
|
||||||
|
author = {Gerard Salton and Christopher Buckley},
|
||||||
|
title = {Term-weighting approaches in automatic text retrieval},
|
||||||
|
journal = {Information Processing \& Management},
|
||||||
|
volume = {24},
|
||||||
|
number = {5},
|
||||||
|
pages = {513--523},
|
||||||
|
year = {1988},
|
||||||
|
doi = {10.1016/0306-4573(88)90021-0}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inproceedings{mccallum1998,
|
||||||
|
author = {Andrew McCallum and Kamal Nigam},
|
||||||
|
title = {A Comparison of Event Models for Naive Bayes Text Classification},
|
||||||
|
booktitle = {AAAI-98 Workshop on Learning for Text Categorization},
|
||||||
|
pages = {41--48},
|
||||||
|
year = {1998}
|
||||||
|
}
|
||||||
|
|
||||||
|
@book{joachims2002,
|
||||||
|
author = {Thorsten Joachims},
|
||||||
|
title = {Learning to Classify Text Using Support Vector Machines},
|
||||||
|
publisher = {Springer},
|
||||||
|
address = {Boston, MA},
|
||||||
|
year = {2002},
|
||||||
|
doi = {10.1007/978-1-4615-0907-3}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inproceedings{vaswani2017,
|
||||||
|
author = {Ashish Vaswani and others},
|
||||||
|
title = {Attention Is All You Need},
|
||||||
|
booktitle = {Advances in Neural Information Processing Systems 30 (NeurIPS 2017)},
|
||||||
|
year = {2017},
|
||||||
|
url = {https://arxiv.org/abs/1706.03762}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{brown2020,
|
||||||
|
author = {Tom B. Brown and others},
|
||||||
|
title = {Language Models are Few-Shot Learners},
|
||||||
|
journal = {arXiv preprint arXiv:2005.14165},
|
||||||
|
year = {2020},
|
||||||
|
url = {https://arxiv.org/abs/2005.14165}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{bommasani2021,
|
||||||
|
author = {Rishi Bommasani and others},
|
||||||
|
title = {On the Opportunities and Risks of Foundation Models},
|
||||||
|
journal = {arXiv preprint arXiv:2108.07258},
|
||||||
|
year = {2021},
|
||||||
|
url = {https://arxiv.org/abs/2108.07258}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{liu2023,
|
||||||
|
author = {Rundong Liu and others},
|
||||||
|
title = {Recent Advances in Hierarchical Multi-label Text Classification: A Survey},
|
||||||
|
journal = {arXiv preprint arXiv:2307.16265},
|
||||||
|
year = {2023},
|
||||||
|
url = {https://arxiv.org/abs/2307.16265}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{guy2006,
|
||||||
|
author = {Marieke Guy and Emma Tonkin},
|
||||||
|
title = {Folksonomies: Tidying up Tags?},
|
||||||
|
journal = {D-Lib Magazine},
|
||||||
|
volume = {12},
|
||||||
|
number = {1},
|
||||||
|
year = {2006},
|
||||||
|
url = {https://mirror.dlib.org/dlib/january06/guy/01guy.html}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{sqlite2026,
|
||||||
|
author = {{SQLite Documentation}},
|
||||||
|
title = {CREATE TABLE},
|
||||||
|
year = {2026},
|
||||||
|
url = {https://www.sqlite.org/lang_createtable.html},
|
||||||
|
note = {Abgerufen am 25.03.2026}
|
||||||
|
}
|
||||||
57
docs/template.typ
Normal file
57
docs/template.typ
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#let conf(
|
||||||
|
title: "Titel der Facharbeit",
|
||||||
|
authors: ("Vorname Nachname",),
|
||||||
|
teacher: none,
|
||||||
|
due_date: none,
|
||||||
|
abstract: none,
|
||||||
|
doc,
|
||||||
|
) = [
|
||||||
|
#set text(
|
||||||
|
font: "New Computer Modern",
|
||||||
|
lang: "de",
|
||||||
|
size: 10pt
|
||||||
|
)
|
||||||
|
#set par(justify: true, leading: 0.65em)
|
||||||
|
#set heading(numbering: "1.")
|
||||||
|
|
||||||
|
// Title + author block (arXiv-like)
|
||||||
|
#set align(center)
|
||||||
|
#v(1.2cm)
|
||||||
|
#block(width: 70%)[
|
||||||
|
#set align(center)
|
||||||
|
#text(17pt, weight: "bold", [#title])
|
||||||
|
#v(0.2cm)
|
||||||
|
#text(12pt, [#authors.join(", ")])
|
||||||
|
#if teacher != none or due_date != none {
|
||||||
|
v(0.3cm)
|
||||||
|
set text(size: 9pt)
|
||||||
|
let meta = (
|
||||||
|
if teacher != none { [Betreuung: #teacher] } else { none },
|
||||||
|
if due_date != none { [Abgabetermin: #due_date] } else { none },
|
||||||
|
).filter(x => x != none)
|
||||||
|
text(meta.join(" · "))
|
||||||
|
set text(size: 10pt)
|
||||||
|
}
|
||||||
|
#v(0.3cm)
|
||||||
|
|
||||||
|
#if abstract != none and abstract != "" {
|
||||||
|
set align(center)
|
||||||
|
text(11pt, weight: "bold", [Abstract])
|
||||||
|
v(0cm)
|
||||||
|
set align(center)
|
||||||
|
set text(size: 9.5pt)
|
||||||
|
abstract
|
||||||
|
set text(size: 10pt)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Inhaltsverzeichnis on a new page, then continue without page break
|
||||||
|
#pagebreak()
|
||||||
|
#set align(left)
|
||||||
|
#heading(level: 1, numbering: none, outlined: false)[Inhaltsverzeichnis]
|
||||||
|
#outline()
|
||||||
|
|
||||||
|
// Document body
|
||||||
|
#pagebreak()
|
||||||
|
#doc
|
||||||
|
]
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::process::Command;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
pub fn classify(input: &str, current_tag_tree: String) -> Result<String> {
|
pub fn classify(tag_tree: &str, content: String) -> Result<String> {
|
||||||
let prompt = format!("You are a resource classifier. Given a hierarchical tag tree and a resource, classify it into 1-3 most specific applicable tags.
|
let prompt = format!("You are a resource classifier. Given a hierarchical tag tree and a resource, classify it into 1-3 most specific applicable tags.
|
||||||
|
|
||||||
# RULES:
|
# RULES:
|
||||||
|
|
@ -12,10 +12,10 @@ pub fn classify(input: &str, current_tag_tree: String) -> Result<String> {
|
||||||
- Output JSON only
|
- Output JSON only
|
||||||
|
|
||||||
# CURRENT TAG TREE:
|
# CURRENT TAG TREE:
|
||||||
{current_tag_tree}
|
{tag_tree}
|
||||||
|
|
||||||
# RESOURCE INFORMATION:
|
# RESOURCE INFORMATION:
|
||||||
{input}
|
{content}
|
||||||
|
|
||||||
# OUTPUT FORMAT:
|
# OUTPUT FORMAT:
|
||||||
{{
|
{{
|
||||||
|
|
@ -35,19 +35,56 @@ pub fn classify(input: &str, current_tag_tree: String) -> Result<String> {
|
||||||
.arg("e")
|
.arg("e")
|
||||||
.arg(prompt)
|
.arg(prompt)
|
||||||
.output()
|
.output()
|
||||||
.with_context(|| "Failed to execute tweet scraping command")?;
|
.with_context(|| "Failed to execute classification command")?;
|
||||||
println!("Output: {:?}", out);
|
println!("Output: {:?}", out);
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn classify_with_retry(
|
||||||
|
tag_tree: &str,
|
||||||
|
content: String,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> Result<ClassificationResult> {
|
||||||
|
for attempt in 1..=max_attempts {
|
||||||
|
match classify(tag_tree, content.clone()) {
|
||||||
|
Ok(json) => match ClassificationResult::from_json(&json) {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Attempt {}/{}: Failed to parse: {}",
|
||||||
|
attempt, max_attempts, e
|
||||||
|
);
|
||||||
|
eprintln!("Raw response: {}", json);
|
||||||
|
if attempt == max_attempts {
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Attempt {}/{}: LLM call failed: {}",
|
||||||
|
attempt, max_attempts, e
|
||||||
|
);
|
||||||
|
if attempt == max_attempts {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
// Yeah
|
// Yeah
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ClassificationResult {
|
pub struct ClassificationResult {
|
||||||
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub confidence: Vec<f32>,
|
pub confidence: Vec<f32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub new_tags: Vec<NewTagSuggestion>,
|
pub new_tags: Vec<NewTagSuggestion>,
|
||||||
|
#[serde(default)]
|
||||||
pub reasoning: String,
|
pub reasoning: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +103,8 @@ impl ClassificationResult {
|
||||||
|
|
||||||
/// Get the most confident tag (if any exist)
|
/// Get the most confident tag (if any exist)
|
||||||
pub fn primary_tag(&self) -> Option<(&str, f32)> {
|
pub fn primary_tag(&self) -> Option<(&str, f32)> {
|
||||||
self.tags.iter()
|
self.tags
|
||||||
|
.iter()
|
||||||
.zip(self.confidence.iter())
|
.zip(self.confidence.iter())
|
||||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||||
.map(|(tag, conf)| (tag.as_str(), *conf))
|
.map(|(tag, conf)| (tag.as_str(), *conf))
|
||||||
|
|
@ -79,7 +117,8 @@ impl ClassificationResult {
|
||||||
|
|
||||||
/// Get tags above confidence threshold
|
/// Get tags above confidence threshold
|
||||||
pub fn confident_tags(&self, threshold: f32) -> Vec<&str> {
|
pub fn confident_tags(&self, threshold: f32) -> Vec<&str> {
|
||||||
self.tags.iter()
|
self.tags
|
||||||
|
.iter()
|
||||||
.zip(self.confidence.iter())
|
.zip(self.confidence.iter())
|
||||||
.filter(|&(_, &conf)| conf >= threshold)
|
.filter(|&(_, &conf)| conf >= threshold)
|
||||||
.map(|(tag, _)| tag.as_str())
|
.map(|(tag, _)| tag.as_str())
|
||||||
|
|
|
||||||
340
src/db.rs
Normal file
340
src/db.rs
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::classifiers::ClassificationResult;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub resource_type: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub saved_at: Option<String>,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TagAssignment {
|
||||||
|
pub tag_path: String,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExportedResource {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub resource_type: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub saved_at: Option<String>,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
pub tags: Vec<TagAssignment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn new(path: &str) -> Result<Self> {
|
||||||
|
let conn = Connection::open(path)
|
||||||
|
.with_context(|| format!("Failed to open database at {}", path))?;
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON", [])
|
||||||
|
.context("Failed to enable foreign keys")?;
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_schema(&self) -> Result<()> {
|
||||||
|
let schema = r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS resources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
saved_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
full_path TEXT NOT NULL UNIQUE,
|
||||||
|
parent_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_tags (
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
tag_path TEXT NOT NULL,
|
||||||
|
confidence REAL NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (resource_id, tag_path),
|
||||||
|
FOREIGN KEY (resource_id) REFERENCES resources(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS classification_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reasoning TEXT,
|
||||||
|
new_tag_suggestions TEXT,
|
||||||
|
FOREIGN KEY (resource_id) REFERENCES resources(id)
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute_batch(schema)
|
||||||
|
.context("Failed to initialize database schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_resource(&self, url: &str, resource_type: &str, content: &str) -> Result<String> {
|
||||||
|
let resource_id = stable_id_for_url(url);
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO resources (id, type, url, content)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(url) DO UPDATE
|
||||||
|
SET type = excluded.type, content = excluded.content
|
||||||
|
"#,
|
||||||
|
params![resource_id, resource_type, url, content],
|
||||||
|
)
|
||||||
|
.context("Failed to insert resource")?;
|
||||||
|
Ok(resource_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource_exists(&self, url: &str) -> Result<bool> {
|
||||||
|
let exists: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM resources WHERE url = ?1)",
|
||||||
|
params![url],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.context("Failed to query resource existence")?;
|
||||||
|
Ok(exists == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_tag_exists(&self, tag_path: &str) -> Result<()> {
|
||||||
|
let parts: Vec<&str> = tag_path
|
||||||
|
.split('/')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
let mut current_parts: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
for part in parts {
|
||||||
|
current_parts.push(part);
|
||||||
|
let full_path = current_parts.join("/");
|
||||||
|
let parent_path = if current_parts.len() > 1 {
|
||||||
|
Some(current_parts[..current_parts.len() - 1].join("/"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR IGNORE INTO tags (full_path, parent_path) VALUES (?1, ?2)",
|
||||||
|
params![full_path, parent_path],
|
||||||
|
)
|
||||||
|
.context("Failed to insert tag")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_tags(&self) -> Result<Vec<String>> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT full_path FROM tags ORDER BY full_path")
|
||||||
|
.context("Failed to prepare tag query")?;
|
||||||
|
let tags = stmt
|
||||||
|
.query_map([], |row| row.get(0))
|
||||||
|
.context("Failed to fetch tags")?
|
||||||
|
.collect::<std::result::Result<Vec<String>, _>>()
|
||||||
|
.context("Failed to collect tags")?;
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_classification(
|
||||||
|
&self,
|
||||||
|
resource_id: &str,
|
||||||
|
result: &ClassificationResult,
|
||||||
|
) -> Result<()> {
|
||||||
|
if result.tags.len() != result.confidence.len() {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: tag/confidence count mismatch ({} tags, {} confidences)",
|
||||||
|
result.tags.len(),
|
||||||
|
result.confidence.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (tag, confidence) in result.tags.iter().zip(result.confidence.iter()) {
|
||||||
|
self.ensure_tag_exists(tag)?;
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO resource_tags (resource_id, tag_path, confidence)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(resource_id, tag_path) DO UPDATE
|
||||||
|
SET confidence = excluded.confidence
|
||||||
|
"#,
|
||||||
|
params![resource_id, tag, confidence],
|
||||||
|
)
|
||||||
|
.context("Failed to insert resource tag")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_tag_suggestions = serde_json::to_string(&result.new_tags)
|
||||||
|
.context("Failed to serialize new tag suggestions")?;
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO classification_log (resource_id, reasoning, new_tag_suggestions)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
"#,
|
||||||
|
params![resource_id, result.reasoning, new_tag_suggestions],
|
||||||
|
)
|
||||||
|
.context("Failed to insert classification log")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_resources_by_tag(&self, tag_path: &str) -> Result<Vec<Resource>> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
r#"
|
||||||
|
SELECT r.id, r.type, r.url, r.title, r.content, r.saved_at, r.metadata
|
||||||
|
FROM resources r
|
||||||
|
INNER JOIN resource_tags rt ON r.id = rt.resource_id
|
||||||
|
WHERE rt.tag_path = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to prepare resource-by-tag query")?;
|
||||||
|
let resources = stmt
|
||||||
|
.query_map(params![tag_path], row_to_resource)
|
||||||
|
.context("Failed to fetch resources by tag")?
|
||||||
|
.collect::<std::result::Result<Vec<Resource>, _>>()
|
||||||
|
.context("Failed to collect resources by tag")?;
|
||||||
|
Ok(resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_unclassified_resources(&self) -> Result<Vec<Resource>> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
r#"
|
||||||
|
SELECT r.id, r.type, r.url, r.title, r.content, r.saved_at, r.metadata
|
||||||
|
FROM resources r
|
||||||
|
LEFT JOIN resource_tags rt ON r.id = rt.resource_id
|
||||||
|
WHERE rt.resource_id IS NULL
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to prepare unclassified resource query")?;
|
||||||
|
let resources = stmt
|
||||||
|
.query_map([], row_to_resource)
|
||||||
|
.context("Failed to fetch unclassified resources")?
|
||||||
|
.collect::<std::result::Result<Vec<Resource>, _>>()
|
||||||
|
.context("Failed to collect unclassified resources")?;
|
||||||
|
Ok(resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_resources_with_tags(&self) -> Result<Vec<ExportedResource>> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
r#"
|
||||||
|
SELECT r.id, r.type, r.url, r.title, r.content, r.saved_at, r.metadata,
|
||||||
|
rt.tag_path, rt.confidence
|
||||||
|
FROM resources r
|
||||||
|
LEFT JOIN resource_tags rt ON r.id = rt.resource_id
|
||||||
|
ORDER BY r.saved_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to prepare export query")?;
|
||||||
|
|
||||||
|
let mut rows = stmt.query([]).context("Failed to query resources")?;
|
||||||
|
let mut resources: HashMap<String, ExportedResource> = HashMap::new();
|
||||||
|
|
||||||
|
while let Some(row) = rows.next().context("Failed to read resource row")? {
|
||||||
|
let resource_id: String = row.get(0)?;
|
||||||
|
let resource_type: String = row.get(1)?;
|
||||||
|
let url: String = row.get(2)?;
|
||||||
|
let title: Option<String> = row.get(3)?;
|
||||||
|
let content: Option<String> = row.get(4)?;
|
||||||
|
let saved_at: Option<String> = row.get(5)?;
|
||||||
|
let metadata: Option<String> = row.get(6)?;
|
||||||
|
let tag_path: Option<String> = row.get(7)?;
|
||||||
|
let confidence: Option<f64> = row.get(8)?;
|
||||||
|
|
||||||
|
let entry = resources
|
||||||
|
.entry(resource_id.clone())
|
||||||
|
.or_insert_with(|| ExportedResource {
|
||||||
|
id: resource_id.clone(),
|
||||||
|
resource_type,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
saved_at,
|
||||||
|
metadata,
|
||||||
|
tags: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if let (Some(tag_path), Some(confidence)) = (tag_path, confidence) {
|
||||||
|
entry.tags.push(TagAssignment {
|
||||||
|
tag_path,
|
||||||
|
confidence: confidence as f32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resources.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_resources(&self) -> Result<i64> {
|
||||||
|
self.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM resources", [], |row| row.get(0))
|
||||||
|
.context("Failed to count resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_tags(&self) -> Result<i64> {
|
||||||
|
self.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))
|
||||||
|
.context("Failed to count tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_classified_resources(&self) -> Result<i64> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(DISTINCT resource_id) FROM resource_tags",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.context("Failed to count classified resources")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_resource(row: &rusqlite::Row<'_>) -> rusqlite::Result<Resource> {
|
||||||
|
Ok(Resource {
|
||||||
|
id: row.get(0)?,
|
||||||
|
resource_type: row.get(1)?,
|
||||||
|
url: row.get(2)?,
|
||||||
|
title: row.get::<_, Option<String>>(3)?,
|
||||||
|
content: row.get::<_, Option<String>>(4)?,
|
||||||
|
saved_at: row.get::<_, Option<String>>(5)?,
|
||||||
|
metadata: row.get::<_, Option<String>>(6)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_id_for_url(url: &str) -> String {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
url.hash(&mut hasher);
|
||||||
|
format!("{:x}", hasher.finish())
|
||||||
|
}
|
||||||
174
src/main.rs
174
src/main.rs
|
|
@ -1,9 +1,14 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
mod classifiers;
|
mod classifiers;
|
||||||
|
mod db;
|
||||||
mod scrapers;
|
mod scrapers;
|
||||||
|
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
enum Source {
|
enum Source {
|
||||||
Twitter,
|
Twitter,
|
||||||
Other,
|
Other,
|
||||||
|
|
@ -17,75 +22,162 @@ fn determine_resource_source(line: &str) -> Source {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "classifier")]
|
||||||
|
#[command(about = "Resource classifier with hierarchical tags")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Classify resources from a file
|
||||||
|
Classify {
|
||||||
|
/// Path to file with URLs
|
||||||
|
#[arg(short, long, default_value = "test-classification-list")]
|
||||||
|
input: String,
|
||||||
|
|
||||||
|
/// Force re-classification of existing resources
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Export resources to JSON
|
||||||
|
Export {
|
||||||
|
/// Output file
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show statistics
|
||||||
|
Stats,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
// Read the file
|
let cli = Cli::parse();
|
||||||
let contents = fs::read_to_string("test-classification-list")
|
|
||||||
.expect("Something went wrong reading the file");
|
println!("Opening database...");
|
||||||
let current_tag_tree =
|
let db = Database::new("resources.db").context("Failed to open database")?;
|
||||||
fs::read_to_string("tag-tree").expect("Something went wrong reading the tag tree file");
|
db.init_schema()
|
||||||
|
.context("Failed to initialize database schema")?;
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Classify { input, force } => classify_resources(&db, &input, force),
|
||||||
|
Commands::Export { output } => export_resources(&db, &output),
|
||||||
|
Commands::Stats => show_stats(&db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_resources(db: &Database, input: &str, force: bool) -> Result<()> {
|
||||||
|
let contents = fs::read_to_string(input)
|
||||||
|
.with_context(|| format!("Failed to read input file: {}", input))?;
|
||||||
|
let tag_tree = fs::read_to_string("tag-tree").context("Failed to read tag tree file")?;
|
||||||
|
|
||||||
// Determine source
|
|
||||||
for line in contents.lines() {
|
for line in contents.lines() {
|
||||||
let source = determine_resource_source(line);
|
let url = line.trim();
|
||||||
|
if url.is_empty() {
|
||||||
match source {
|
continue;
|
||||||
Source::Twitter => {
|
}
|
||||||
println!("Classifying Twitter resource: {}", line);
|
|
||||||
|
let exists = db.resource_exists(url)?;
|
||||||
// Scrape the Tweet
|
if exists && !force {
|
||||||
let tweet_file = scrapers::twitter::scrape(line);
|
println!("Skipping already-classified resource: {}", url);
|
||||||
let tweet_scrape_contents = match fs::read_to_string(tweet_file.unwrap())
|
continue;
|
||||||
.with_context(|| "Something went wrong reading the scraped tweet file")
|
}
|
||||||
{
|
|
||||||
Err(e) => {
|
if exists && force {
|
||||||
eprintln!("Error reading scraped tweet file: {:?}", e);
|
println!("Re-classifying existing resource: {}", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = determine_resource_source(url);
|
||||||
|
match source {
|
||||||
|
Source::Twitter => {
|
||||||
|
println!("Classifying Twitter resource: {}", url);
|
||||||
|
|
||||||
|
let tweet_file = match scrapers::twitter::scrape(url) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error scraping tweet {}: {}", url, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(contents) => contents,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let classifier_output =
|
let tweet = match scrapers::twitter::parse_scraped_tweet(&tweet_file) {
|
||||||
classifiers::classify(¤t_tag_tree, tweet_scrape_contents);
|
Ok(tweet) => tweet,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error parsing tweet {}: {}", url, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = format!("Title: Tweet by @{}\nContent: {}", tweet.author, tweet.text);
|
||||||
|
let resource_id = db.insert_resource(url, "twitter", &content)?;
|
||||||
|
|
||||||
|
let result = match classifiers::classify_with_retry(&tag_tree, content, 3) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Classification failed for {}: {}", url, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match classifier_output {
|
|
||||||
Ok(json_string) => {
|
|
||||||
match classifiers::ClassificationResult::from_json(&json_string) {
|
|
||||||
Ok(result) => {
|
|
||||||
println!("Tags: {:?}", result.tags);
|
println!("Tags: {:?}", result.tags);
|
||||||
println!("Confidence: {:?}", result.confidence);
|
println!("Confidence: {:?}", result.confidence);
|
||||||
println!("Reasoning: {}", result.reasoning);
|
println!("Reasoning: {}", result.reasoning);
|
||||||
|
|
||||||
// Check if we need to review new tags
|
|
||||||
if !result.new_tags.is_empty() {
|
if !result.new_tags.is_empty() {
|
||||||
println!("\n🆕 New tag suggestions:");
|
println!("\nNew tag suggestions:");
|
||||||
for suggestion in &result.new_tags {
|
for suggestion in &result.new_tags {
|
||||||
println!(
|
println!(" - {} (under {})", suggestion.name, suggestion.parent);
|
||||||
" - {} (under {})",
|
|
||||||
suggestion.name, suggestion.parent
|
|
||||||
);
|
|
||||||
println!(" Reason: {}", suggestion.reason);
|
println!(" Reason: {}", suggestion.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only use high-confidence tags
|
|
||||||
let confident = result.confident_tags(0.5);
|
let confident = result.confident_tags(0.5);
|
||||||
if confident.is_empty() {
|
if confident.is_empty() {
|
||||||
println!("⚠️ Low confidence classification - review needed");
|
println!("Low confidence classification - review needed");
|
||||||
} else {
|
} else {
|
||||||
println!("✅ Confident tags: {:?}", confident);
|
println!("Confident tags: {:?}", confident);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Failed to parse classification: {}", e),
|
if let Err(e) = db.store_classification(&resource_id, &result) {
|
||||||
}
|
eprintln!("Failed to store classification for {}: {}", url, e);
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Classification failed: {}", e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Source::Other => {
|
Source::Other => {
|
||||||
eprintln!("Classification of this source/website is not covered yet!");
|
eprintln!(
|
||||||
|
"Classification of this source/website is not covered yet: {}",
|
||||||
|
url
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn export_resources(db: &Database, output: &str) -> Result<()> {
|
||||||
|
let resources = db
|
||||||
|
.get_resources_with_tags()
|
||||||
|
.context("Failed to fetch resources for export")?;
|
||||||
|
let json = serde_json::to_string_pretty(&resources)
|
||||||
|
.context("Failed to serialize resources to JSON")?;
|
||||||
|
fs::write(output, json).with_context(|| format!("Failed to write export file: {}", output))?;
|
||||||
|
println!("Exported {} resources to {}", resources.len(), output);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_stats(db: &Database) -> Result<()> {
|
||||||
|
let total_resources = db.count_resources()?;
|
||||||
|
let classified_resources = db.count_classified_resources()?;
|
||||||
|
let tag_count = db.count_tags()?;
|
||||||
|
let unclassified = total_resources.saturating_sub(classified_resources);
|
||||||
|
|
||||||
|
println!("Resources: {}", total_resources);
|
||||||
|
println!("Classified resources: {}", classified_resources);
|
||||||
|
println!("Unclassified resources: {}", unclassified);
|
||||||
|
println!("Tags: {}", tag_count);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,83 @@
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use std::{path::PathBuf, process::Command};
|
use serde::Deserialize;
|
||||||
|
use std::{fs, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ScrapedTweet {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub author: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawScrapedTweet {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "full_text")]
|
||||||
|
pub text: String,
|
||||||
|
pub author: Option<TweetAuthor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TweetAuthor {
|
||||||
|
#[serde(rename = "screen_name")]
|
||||||
|
pub handle: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_scraped_tweet(path: &PathBuf) -> Result<ScrapedTweet> {
|
||||||
|
let contents = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("Failed to read scraped tweet file: {}", path.display()))?;
|
||||||
|
|
||||||
|
if let Ok(raw) = toml::from_str::<RawScrapedTweet>(&contents) {
|
||||||
|
let author = raw
|
||||||
|
.author
|
||||||
|
.map(|author| author.handle)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
return Ok(ScrapedTweet {
|
||||||
|
id: raw.id,
|
||||||
|
text: raw.text,
|
||||||
|
author,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_scraped_tweet_fallback(&contents, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scraped_tweet_fallback(contents: &str, path: &PathBuf) -> Result<ScrapedTweet> {
|
||||||
|
let mut id = None;
|
||||||
|
let mut text = None;
|
||||||
|
let mut author = None;
|
||||||
|
|
||||||
|
for line in contents.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("id = ") && id.is_none() {
|
||||||
|
id = parse_quoted_value(trimmed);
|
||||||
|
} else if trimmed.starts_with("full_text = ") && text.is_none() {
|
||||||
|
text = parse_quoted_value(trimmed).map(unescape_toml_string);
|
||||||
|
} else if trimmed.starts_with("screen_name = ") && author.is_none() {
|
||||||
|
author = parse_quoted_value(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = id.with_context(|| format!("Missing id in scraped tweet: {}", path.display()))?;
|
||||||
|
let text =
|
||||||
|
text.with_context(|| format!("Missing full_text in scraped tweet: {}", path.display()))?;
|
||||||
|
let author = author.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
Ok(ScrapedTweet { id, text, author })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_quoted_value(line: &str) -> Option<String> {
|
||||||
|
let start = line.find('"')?;
|
||||||
|
let end = line.rfind('"')?;
|
||||||
|
if end <= start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(line[start + 1..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape_toml_string(value: String) -> String {
|
||||||
|
value.replace("\\n", "\n").replace("\\\"", "\"")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scrape(url: &str) -> Result<PathBuf> {
|
pub fn scrape(url: &str) -> Result<PathBuf> {
|
||||||
let tweet_id = url.split('/').next_back().unwrap();
|
let tweet_id = url.split('/').next_back().unwrap();
|
||||||
|
|
|
||||||
2
tag-tree
2
tag-tree
|
|
@ -149,3 +149,5 @@
|
||||||
- window_managers
|
- window_managers
|
||||||
- web_technologies
|
- web_technologies
|
||||||
- wasm
|
- wasm
|
||||||
|
- Ai
|
||||||
|
- LLMs
|
||||||
|
|
|
||||||
|
|
@ -1 +1,8 @@
|
||||||
https://x.com/fleetwood___/status/1987527758558228809
|
https://x.com/fleetwood___/status/1987527758558228809
|
||||||
|
https://x.com/thegeneralist01/status/2017960363099107400
|
||||||
|
https://x.com/thegeneralist01/status/2007161972442145086
|
||||||
|
https://x.com/TigerBeetleDB/status/2019013589705916447
|
||||||
|
https://x.com/TigerBeetleDB/status/2019013589705916447
|
||||||
|
https://x.com/mitchellh/status/2020252149117313349
|
||||||
|
https://x.com/amitpr/status/2020263065745519001
|
||||||
|
https://x.com/claudeai/status/2020207322124132504
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue