Compare commits
19 commits
chore/rebr
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d7f8b6683 | |||
|
|
0790b2b6d0 | ||
| 90502a3eed | |||
|
|
ba9b61fcf3 | ||
|
|
f916787962 | ||
|
|
6632d63239 | ||
|
|
5e55861a3f | ||
|
|
19c92ccd52 | ||
|
|
53b4f572df | ||
| ad2ba1181a | |||
|
|
0558ac5c4f | ||
|
|
ce0bf8dafd | ||
|
|
9e7291214b | ||
|
|
cd580f3bec | ||
|
|
8f4af514a3 | ||
| a30c18ac44 | |||
|
|
b5f12a500c | ||
|
|
ff9f9455a6 | ||
|
|
ec15893c9c |
15 changed files with 751 additions and 94 deletions
151
Cargo.lock
generated
151
Cargo.lock
generated
|
|
@ -517,6 +517,18 @@ version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "auto_enums"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3091d68264354f211516b91dce6f71046e444fab1867716035f736667243affb"
|
||||||
|
dependencies = [
|
||||||
|
"derive_utils",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
|
|
@ -1068,6 +1080,15 @@ version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_maths"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||||
|
dependencies = [
|
||||||
|
"libm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -2039,6 +2060,17 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_utils"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
|
@ -2097,6 +2129,8 @@ dependencies = [
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"text-splitter",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -2874,11 +2908,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
|
"serde",
|
||||||
"tinystr",
|
"tinystr",
|
||||||
"writeable",
|
"writeable",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_fallback"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||||
|
dependencies = [
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_locale_fallback_data",
|
||||||
|
"icu_provider",
|
||||||
|
"potential_utf",
|
||||||
|
"tinystr",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_fallback_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_normalizer"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -2928,6 +2983,8 @@ checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
|
"serde",
|
||||||
|
"stable_deref_trait",
|
||||||
"writeable",
|
"writeable",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
|
@ -2935,6 +2992,29 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_segmenter"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
|
||||||
|
dependencies = [
|
||||||
|
"core_maths",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_fallback",
|
||||||
|
"icu_provider",
|
||||||
|
"icu_segmenter_data",
|
||||||
|
"potential_utf",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_segmenter_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -4763,6 +4843,8 @@ version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"writeable",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -5414,9 +5496,9 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig"
|
name = "rig"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ce03971e6115d30ef53fb3244d06718a4e62bbde82c103065600c09459b989a"
|
checksum = "b989f4060e5f3c1ea4b8b6999f6759949d820704286cc6976b7a688cc0133029"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rig-agent",
|
"rig-agent",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
|
|
@ -5429,9 +5511,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-agent"
|
name = "rig-agent"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b0796bbf47d7b76670401aac975bc619cf7fba3482b22dfe14992edaa9c2e04"
|
checksum = "b66477c0d0a786358d078e7e3541a73089dd9009100d6e2b3fe3b0d730e8550f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
|
|
@ -5451,9 +5533,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-core"
|
name = "rig-core"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e"
|
checksum = "432d83e0facf16749f91fe729cbffca84437e8062d2f4e92f4f12e903693922d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as-any",
|
"as-any",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|
@ -5475,6 +5557,7 @@ dependencies = [
|
||||||
"schemars 1.2.2",
|
"schemars 1.2.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
@ -5485,9 +5568,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-derive"
|
name = "rig-derive"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f"
|
checksum = "de0a33f1bac45f16e50146c248bcbbfaa44518c7252d274e972c7f4ad71aaba7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convert_case",
|
"convert_case",
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
|
|
@ -5498,9 +5581,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-fastembed"
|
name = "rig-fastembed"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69ba93356242940918fac76a4b1cfe78b964f757e5cb65bb3feebd597635cfb0"
|
checksum = "f1dff99f9bea13108bb14df5431b6fc0738beb87f1fe5ebfcf26ae9fe54a188d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastembed",
|
"fastembed",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
|
|
@ -5512,9 +5595,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-helixdb"
|
name = "rig-helixdb"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d1d40ae8fa89af33793626b898ec527e246183ef8b4b5095afee5f6a65cbbb1a"
|
checksum = "b5fa2b188a1e9e65d29c34738c97465f0822c57540d6d45fd4f04dad906c966a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
|
|
@ -5525,12 +5608,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-lancedb"
|
name = "rig-lancedb"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f703b9e081ee4d77e60953598a5071a5cff4b8b702a2f06d251310950b0dae7c"
|
checksum = "c5fe3555202c5bd4e30948414c2e234fddd9260d4520046733fcf0d58718f3a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"deranged",
|
|
||||||
"futures",
|
"futures",
|
||||||
"lancedb",
|
"lancedb",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
|
|
@ -5540,9 +5622,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-milvus"
|
name = "rig-milvus"
|
||||||
version = "0.41.0"
|
version = "0.42.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76662eb7f75de4e54b5f71e616430dc7f75d96d32f109d9435ad426b511eb565"
|
checksum = "af9c7b7ff7ed9c16da983a21f1e81f425c9805355427a92f4d2d97ba1fe2aa12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
|
|
@ -6409,6 +6491,23 @@ dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "text-splitter"
|
||||||
|
version = "0.32.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f3eeec76988617ff1434d754d7e8e197be2cb8981ea77c17e32f3f7a2c6f95e"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"auto_enums",
|
||||||
|
"either",
|
||||||
|
"icu_provider",
|
||||||
|
"icu_segmenter",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"memchr",
|
||||||
|
"strum 0.28.0",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
|
|
@ -6507,6 +6606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
|
"serde_core",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -6610,9 +6710,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -6799,9 +6899,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
|
|
@ -6813,7 +6913,6 @@ dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"utf-8",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -6934,12 +7033,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf-8"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8-ranges"
|
name = "utf8-ranges"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
@ -7560,6 +7653,7 @@ dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -7568,6 +7662,7 @@ version = "0.11.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerovec-derive",
|
"zerovec-derive",
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,15 @@ clap = { version = "4", features = ["derive"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
indicatif = "0.18.6"
|
indicatif = "0.18.6"
|
||||||
reqwest = { version = "0.13.4", features = ["query", "json"] }
|
reqwest = { version = "0.13.4", features = ["query", "json"] }
|
||||||
rig = "0.41.0"
|
rig = "0.42.0"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
scraper = "0.27"
|
scraper = "0.27"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
text-splitter = "0.32"
|
||||||
tokio = { version = "1.53.1", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -27,13 +27,14 @@ failure was fixed, not just papered over.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Four small agents, each with one job, coordinated by plain Rust control
|
Four small agents, each with one job, coordinated by plain Rust control
|
||||||
flow — not a framework's agent graph, not an LLM deciding when to stop:
|
flow — not a framework's agent graph, not an LLM deciding when to stop —
|
||||||
|
plus an optional retrieval step when `--doc` documents are supplied:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ approve/reject ┌──────────┐
|
┌────────────┐ excerpts ┌─────────────┐ approve/reject ┌──────────┐
|
||||||
│ researcher │ ───────────────► │ reviewer │
|
│ retriever │ ───────────► │ researcher │ ───────────────► │ reviewer │
|
||||||
│ (tool-using)│ ◄─────────────── │ │
|
│(--doc only)│ │ (tool-using)│ ◄─────────────── │ │
|
||||||
└──────┬──────┘ gaps/feedback └────┬─────┘
|
└────────────┘ └──────┬──────┘ gaps/feedback └────┬─────┘
|
||||||
│ turn budget exhausted │ approved,
|
│ turn budget exhausted │ approved,
|
||||||
│ mid-investigation │ or out of rounds
|
│ mid-investigation │ or out of rounds
|
||||||
▼ ▼
|
▼ ▼
|
||||||
|
|
@ -43,6 +44,10 @@ flow — not a framework's agent graph, not an LLM deciding when to stop:
|
||||||
└──────────────┘ └──────────────┘
|
└──────────────┘ └──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **retriever** (only when `--doc` paths are given) — chunks and embeds
|
||||||
|
user-supplied documents with a dedicated embedding model, then retrieves
|
||||||
|
the excerpts most relevant to the topic once up front and folds them into
|
||||||
|
the researcher's task, cited with the same footnote scheme as web sources.
|
||||||
- **researcher** — a tool-calling agent (`search_web`, `fetch_page`) that
|
- **researcher** — a tool-calling agent (`search_web`, `fetch_page`) that
|
||||||
gathers and cross-checks evidence, capped at a fixed model-call budget so
|
gathers and cross-checks evidence, capped at a fixed model-call budget so
|
||||||
a confused model can't loop forever.
|
a confused model can't loop forever.
|
||||||
|
|
@ -79,8 +84,33 @@ cargo run -- "your research topic"
|
||||||
|
|
||||||
# or, with tracing spans on stderr instead of the progress spinner:
|
# or, with tracing spans on stderr instead of the progress spinner:
|
||||||
cargo run -- -l info "your research topic"
|
cargo run -- -l info "your research topic"
|
||||||
|
|
||||||
|
# give the researcher your own documents to draw on, alongside the web —
|
||||||
|
# repeatable, and a directory contributes every file directly inside it
|
||||||
|
# (one level deep, not recursive):
|
||||||
|
cargo run -- --doc ./notes.txt --doc ./research-docs/ "your research topic"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Uploaded documents are chunked (see `documents.rs`), embedded with a
|
||||||
|
dedicated embedding model (see `EMBEDDING_MODEL` in
|
||||||
|
[`src/models.rs`](./src/models.rs)) into an in-memory vector index, then the
|
||||||
|
excerpts most relevant to the topic are retrieved and folded into the
|
||||||
|
researcher's task alongside anything it finds on the web — the same
|
||||||
|
footnote-citation scheme applies to both.
|
||||||
|
|
||||||
|
Known limitation: retrieval returns a fixed top-N chunks
|
||||||
|
(`retrieval::TOP_N_EXCERPTS`). A document with several chunks that all read
|
||||||
|
as similar to the query — several incident reports, several revisions of
|
||||||
|
the same section — can crowd out the one chunk that actually answers it,
|
||||||
|
since only the top N by similarity are ever returned regardless of how many
|
||||||
|
plausible candidates exist. Reproduced deliberately (a 13.7 KB / 12-chunk
|
||||||
|
document with 6 near-identical "incident report" sections, only one of
|
||||||
|
which had the real answer, was built specifically to stress this — the top
|
||||||
|
5 slots filled entirely with distractors and the answer chunk was
|
||||||
|
excluded), so it's a real edge case, not a hypothetical. Not fixed for now
|
||||||
|
since it takes a document engineered to trigger it, but worth knowing if a
|
||||||
|
report seems to be missing something you know is in an uploaded document.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
Split one concern per file rather than one large module:
|
Split one concern per file rather than one large module:
|
||||||
|
|
@ -94,6 +124,8 @@ Split one concern per file rather than one large module:
|
||||||
| `summarizer.rs` | Max-turns recovery: reconstructs findings via a model call |
|
| `summarizer.rs` | Max-turns recovery: reconstructs findings via a model call |
|
||||||
| `writer.rs` | Turns findings into the final streamed report |
|
| `writer.rs` | Turns findings into the final streamed report |
|
||||||
| `history.rs` | Pure, unit-tested helpers for parsing a rig chat history into usable text |
|
| `history.rs` | Pure, unit-tested helpers for parsing a rig chat history into usable text |
|
||||||
|
| `documents.rs` | Resolves `--doc` paths into embeddable documents |
|
||||||
|
| `retrieval.rs` | Embeds documents into an in-memory vector index and retrieves relevant excerpts |
|
||||||
| `tools.rs` | `search_web` (SearXNG) and `fetch_page` tool implementations |
|
| `tools.rs` | `search_web` (SearXNG) and `fetch_page` tool implementations |
|
||||||
| `stream.rs` | Drains a streaming prompt response to the terminal |
|
| `stream.rs` | Drains a streaming prompt response to the terminal |
|
||||||
| `progress.rs` | The terminal spinner and per-phase emoji |
|
| `progress.rs` | The terminal spinner and per-phase emoji |
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub(crate) const DEFAULT_TOPIC: &str =
|
pub(crate) const DEFAULT_TOPIC: &str =
|
||||||
"What are the latest advances in running large language models locally, on consumer hardware?";
|
"What are the latest advances in running large language models locally, on consumer hardware?";
|
||||||
|
|
@ -16,4 +17,11 @@ pub(crate) struct Doubleo7 {
|
||||||
/// progress spinner to switch off, since the logs already show progress)
|
/// progress spinner to switch off, since the logs already show progress)
|
||||||
#[arg(short = 'l', long, value_name = "LEVEL")]
|
#[arg(short = 'l', long, value_name = "LEVEL")]
|
||||||
pub(crate) log_level: Option<tracing::Level>,
|
pub(crate) log_level: Option<tracing::Level>,
|
||||||
|
|
||||||
|
/// A file, or a directory of files, to embed and make available to the
|
||||||
|
/// researcher as retrieved excerpts (repeatable). Directories are
|
||||||
|
/// scanned one level deep — non-directory entries only, subdirectories
|
||||||
|
/// are skipped rather than walked.
|
||||||
|
#[arg(short = 'd', long = "doc", value_name = "PATH")]
|
||||||
|
pub(crate) docs: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
345
src/documents.rs
Normal file
345
src/documents.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
use rig::embeddings::{EmbedError, TextEmbedder};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use text_splitter::{Characters, TextSplitter};
|
||||||
|
|
||||||
|
/// Target chunk size handed to the embedding model: small enough that a
|
||||||
|
/// handful of retrieved chunks stays well within a local model's context
|
||||||
|
/// window, large enough to keep a paragraph or two of context in each one.
|
||||||
|
/// `TextSplitter` treats this as an upper bound, not a fixed size — it
|
||||||
|
/// recursively splits on the largest semantic boundary (paragraph,
|
||||||
|
/// sentence, word, ...) that still fits, so a chunk never cuts a sentence
|
||||||
|
/// mid-word just to hit the target exactly.
|
||||||
|
const CHUNK_CHARS: usize = 1_500;
|
||||||
|
|
||||||
|
/// A single chunk of an uploaded document, embedded via its own text so
|
||||||
|
/// retrieval can surface just the passage relevant to a query rather than
|
||||||
|
/// an entire (possibly very long) file at once.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct Document {
|
||||||
|
pub(crate) source: String,
|
||||||
|
pub(crate) text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
/// Builds a `Document` for the `index`-th of `total` chunks split from
|
||||||
|
/// `path`, labeling multi-chunk files with a "(part N/total)" suffix so
|
||||||
|
/// retrieved excerpts can be traced back to their position in the
|
||||||
|
/// source file.
|
||||||
|
fn from_chunk(path: &Path, index: usize, chunk: &str, total: usize) -> Self {
|
||||||
|
let source = match total {
|
||||||
|
total_chunks if total_chunks > 1 => {
|
||||||
|
format!("{} (part {}/{total})", path.display(), index + 1)
|
||||||
|
}
|
||||||
|
_ => path.display().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
source,
|
||||||
|
text: chunk.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rig::Embed for Document {
|
||||||
|
fn embed(&self, embedder: &mut TextEmbedder) -> Result<(), EmbedError> {
|
||||||
|
embedder.embed(self.text.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves CLI-provided paths into chunked documents to embed: a file is
|
||||||
|
/// read and split into chunks, a directory contributes every non-directory
|
||||||
|
/// entry inside it — one level deep, not recursive, so a stray nested
|
||||||
|
/// folder can't silently pull in unrelated files. Unreadable entries
|
||||||
|
/// (permissions, non-UTF-8) are skipped with a warning rather than failing
|
||||||
|
/// the whole run.
|
||||||
|
pub(crate) fn collect_documents(paths: &[PathBuf]) -> anyhow::Result<Vec<Document>> {
|
||||||
|
let splitter = TextSplitter::new(CHUNK_CHARS);
|
||||||
|
let mut documents = Vec::new();
|
||||||
|
|
||||||
|
let (directories, files): (Vec<&PathBuf>, Vec<&PathBuf>) =
|
||||||
|
paths.iter().partition(|path| path.is_dir());
|
||||||
|
|
||||||
|
for dir in directories {
|
||||||
|
std::fs::read_dir(dir)?
|
||||||
|
.map(|entry| -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
let entry = entry?;
|
||||||
|
Ok(entry.file_type()?.is_file().then(|| entry.path()))
|
||||||
|
})
|
||||||
|
.filter_map(Result::transpose)
|
||||||
|
.collect::<anyhow::Result<Vec<PathBuf>>>()?
|
||||||
|
.iter()
|
||||||
|
.for_each(|path| documents.extend(chunk_document(path, &splitter)));
|
||||||
|
}
|
||||||
|
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.for_each(|file| documents.extend(chunk_document(file, &splitter)));
|
||||||
|
|
||||||
|
Ok(documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `path` and splits it into chunked `Document`s via `splitter`. An
|
||||||
|
/// unreadable path (permissions, non-UTF-8, gone by the time it's read)
|
||||||
|
/// yields an empty `Vec` and a warning rather than failing the caller.
|
||||||
|
fn chunk_document(path: &Path, splitter: &TextSplitter<Characters>) -> Vec<Document> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => {
|
||||||
|
let chunks: Vec<&str> = splitter.chunks(&text).collect();
|
||||||
|
let chunk_count = chunks.len();
|
||||||
|
|
||||||
|
chunks
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(chunk_index, chunk)| {
|
||||||
|
Document::from_chunk(path, chunk_index, chunk, chunk_count)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(path = %path.display(), %err, "skipping unreadable document");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_reads_an_explicit_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("notes.txt");
|
||||||
|
std::fs::write(&file, "hello from a file").unwrap();
|
||||||
|
|
||||||
|
let documents = collect_documents(std::slice::from_ref(&file)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(documents.len(), 1);
|
||||||
|
assert_eq!(documents[0].source, file.display().to_string());
|
||||||
|
assert_eq!(documents[0].text, "hello from a file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_reads_every_file_in_a_directory_non_recursively() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("a.txt"), "a").unwrap();
|
||||||
|
std::fs::write(dir.path().join("b.txt"), "b").unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("nested")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("nested/c.txt"), "c").unwrap();
|
||||||
|
|
||||||
|
let mut documents = collect_documents(&[dir.path().to_path_buf()]).unwrap();
|
||||||
|
documents.sort_by(|a, b| a.source.cmp(&b.source));
|
||||||
|
|
||||||
|
assert_eq!(documents.len(), 2);
|
||||||
|
assert_eq!(documents[0].text, "a");
|
||||||
|
assert_eq!(documents[1].text, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_is_empty_for_no_paths() {
|
||||||
|
assert!(collect_documents(&[]).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_skips_unreadable_paths_instead_of_failing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let missing = dir.path().join("does-not-exist.txt");
|
||||||
|
|
||||||
|
assert!(collect_documents(&[missing]).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_splits_a_large_file_into_multiple_chunks() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("big.txt");
|
||||||
|
// Well over CHUNK_CHARS, and with paragraph breaks so the splitter
|
||||||
|
// has real semantic boundaries to chunk on.
|
||||||
|
let paragraph = "word ".repeat(100);
|
||||||
|
std::fs::write(&file, vec![paragraph; 10].join("\n\n")).unwrap();
|
||||||
|
|
||||||
|
let documents = collect_documents(std::slice::from_ref(&file)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
documents.len() > 1,
|
||||||
|
"expected a large file to produce multiple chunks"
|
||||||
|
);
|
||||||
|
for (i, doc) in documents.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
doc.source,
|
||||||
|
format!("{} (part {}/{})", file.display(), i + 1, documents.len())
|
||||||
|
);
|
||||||
|
assert!(!doc.text.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_embed_yields_its_full_text() {
|
||||||
|
let document = Document {
|
||||||
|
source: "s".to_string(),
|
||||||
|
text: "the text to embed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rig::embeddings::to_texts(document).unwrap(),
|
||||||
|
vec!["the text to embed".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_reads_multiple_explicit_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let a = dir.path().join("a.txt");
|
||||||
|
let b = dir.path().join("b.txt");
|
||||||
|
std::fs::write(&a, "a").unwrap();
|
||||||
|
std::fs::write(&b, "b").unwrap();
|
||||||
|
|
||||||
|
let mut documents = collect_documents(&[a, b]).unwrap();
|
||||||
|
documents.sort_by(|x, y| x.text.cmp(&y.text));
|
||||||
|
|
||||||
|
assert_eq!(documents.len(), 2);
|
||||||
|
assert_eq!(documents[0].text, "a");
|
||||||
|
assert_eq!(documents[1].text, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_reads_multiple_directories() {
|
||||||
|
let first = tempdir().unwrap();
|
||||||
|
let second = tempdir().unwrap();
|
||||||
|
std::fs::write(first.path().join("a.txt"), "a").unwrap();
|
||||||
|
std::fs::write(second.path().join("b.txt"), "b").unwrap();
|
||||||
|
|
||||||
|
let mut documents =
|
||||||
|
collect_documents(&[first.path().to_path_buf(), second.path().to_path_buf()]).unwrap();
|
||||||
|
documents.sort_by(|x, y| x.text.cmp(&y.text));
|
||||||
|
|
||||||
|
assert_eq!(documents.len(), 2);
|
||||||
|
assert_eq!(documents[0].text, "a");
|
||||||
|
assert_eq!(documents[1].text, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_handles_a_mix_of_files_and_directories_in_one_call() {
|
||||||
|
let explicit = tempdir().unwrap();
|
||||||
|
let explicit_file = explicit.path().join("explicit.txt");
|
||||||
|
std::fs::write(&explicit_file, "explicit").unwrap();
|
||||||
|
|
||||||
|
let scanned = tempdir().unwrap();
|
||||||
|
std::fs::write(scanned.path().join("scanned.txt"), "scanned").unwrap();
|
||||||
|
|
||||||
|
let mut documents =
|
||||||
|
collect_documents(&[explicit_file, scanned.path().to_path_buf()]).unwrap();
|
||||||
|
documents.sort_by(|x, y| x.text.cmp(&y.text));
|
||||||
|
|
||||||
|
assert_eq!(documents.len(), 2);
|
||||||
|
assert_eq!(documents[0].text, "explicit");
|
||||||
|
assert_eq!(documents[1].text, "scanned");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix permission bits are meaningless to a root process — it can read
|
||||||
|
/// anything regardless of mode — so the two `chmod 0o000` tests below
|
||||||
|
/// would fail under a root-run CI container (e.g. an unmodified Docker
|
||||||
|
/// base image) despite the code being correct. Skip rather than assert
|
||||||
|
/// behavior the OS isn't actually enforcing.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn running_as_root() -> bool {
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn geteuid() -> u32;
|
||||||
|
}
|
||||||
|
unsafe { geteuid() == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_skips_an_unreadable_file_inside_a_directory_but_keeps_the_rest() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
if running_as_root() {
|
||||||
|
eprintln!("skipping: running as root, chmod 0o000 has no effect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let readable = dir.path().join("readable.txt");
|
||||||
|
let unreadable = dir.path().join("unreadable.txt");
|
||||||
|
std::fs::write(&readable, "readable").unwrap();
|
||||||
|
std::fs::write(&unreadable, "unreadable").unwrap();
|
||||||
|
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let documents = collect_documents(&[dir.path().to_path_buf()]);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
|
|
||||||
|
let documents = documents.unwrap();
|
||||||
|
assert_eq!(documents.len(), 1);
|
||||||
|
assert_eq!(documents[0].text, "readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn collect_documents_fails_outright_on_an_unreadable_directory() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
if running_as_root() {
|
||||||
|
eprintln!("skipping: running as root, chmod 0o000 has no effect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let result = collect_documents(&[dir.path().to_path_buf()]);
|
||||||
|
|
||||||
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"an unreadable directory itself should fail the whole call, unlike an unreadable file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_document_splits_a_readable_file_by_the_given_splitter() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("small.txt");
|
||||||
|
std::fs::write(&file, "hello").unwrap();
|
||||||
|
let splitter = TextSplitter::new(CHUNK_CHARS);
|
||||||
|
|
||||||
|
let chunks = chunk_document(&file, &splitter);
|
||||||
|
|
||||||
|
assert_eq!(chunks.len(), 1);
|
||||||
|
assert_eq!(chunks[0].text, "hello");
|
||||||
|
assert_eq!(chunks[0].source, file.display().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chunk_document_returns_empty_for_an_unreadable_path() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let missing = dir.path().join("does-not-exist.txt");
|
||||||
|
let splitter = TextSplitter::new(CHUNK_CHARS);
|
||||||
|
|
||||||
|
assert!(chunk_document(&missing, &splitter).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_from_chunk_uses_the_bare_path_when_there_is_only_one_chunk() {
|
||||||
|
let path = Path::new("/tmp/notes.txt");
|
||||||
|
|
||||||
|
let document = Document::from_chunk(path, 0, "text", 1);
|
||||||
|
|
||||||
|
assert_eq!(document.source, "/tmp/notes.txt");
|
||||||
|
assert_eq!(document.text, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_from_chunk_appends_a_part_suffix_when_there_are_multiple_chunks() {
|
||||||
|
let path = Path::new("/tmp/notes.txt");
|
||||||
|
|
||||||
|
let first = Document::from_chunk(path, 0, "a", 3);
|
||||||
|
let last = Document::from_chunk(path, 2, "c", 3);
|
||||||
|
|
||||||
|
assert_eq!(first.source, "/tmp/notes.txt (part 1/3)");
|
||||||
|
assert_eq!(last.source, "/tmp/notes.txt (part 3/3)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -149,11 +149,11 @@ mod tests {
|
||||||
fn assistant_text_ignores_tool_calls() {
|
fn assistant_text_ignores_tool_calls() {
|
||||||
let message = Message::Assistant {
|
let message = Message::Assistant {
|
||||||
id: None,
|
id: None,
|
||||||
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
content: vec![AssistantContent::tool_call(
|
||||||
"call-1",
|
"call-1",
|
||||||
"search_web",
|
"search_web",
|
||||||
serde_json::json!({ "query": "test"}),
|
serde_json::json!({ "query": "test"}),
|
||||||
)),
|
)],
|
||||||
};
|
};
|
||||||
assert!(extract_assistant_text(&message).is_empty());
|
assert!(extract_assistant_text(&message).is_empty());
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +166,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_result_text_extracts_and_truncates() {
|
fn tool_result_text_extracts_and_truncates() {
|
||||||
let short = Message::tool_result("call-1", "short result");
|
let short = Message::tool_result("call-1", "call-1-name", "short result");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool_result_text(&short, 100),
|
tool_result_text(&short, 100),
|
||||||
vec!["short result".to_string()]
|
vec!["short result".to_string()]
|
||||||
);
|
);
|
||||||
|
|
||||||
let long = Message::tool_result("call-2", "0123456789");
|
let long = Message::tool_result("call-2", "call-2-result", "0123456789");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool_result_text(&long, 5),
|
tool_result_text(&long, 5),
|
||||||
vec!["01234 ...[truncated]".to_string()]
|
vec!["01234 ...[truncated]".to_string()]
|
||||||
|
|
@ -191,11 +191,11 @@ mod tests {
|
||||||
Message::system("preamble"),
|
Message::system("preamble"),
|
||||||
Message::Assistant {
|
Message::Assistant {
|
||||||
id: None,
|
id: None,
|
||||||
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
content: vec![AssistantContent::tool_call(
|
||||||
"call-1",
|
"call-1",
|
||||||
"search_web",
|
"search_web",
|
||||||
serde_json::json!({ "query": "test" }),
|
serde_json::json!({ "query": "test" }),
|
||||||
)),
|
)],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -209,9 +209,9 @@ mod tests {
|
||||||
fn partial_findings_from_history_collects_assistant_text_and_tool_results_in_order() {
|
fn partial_findings_from_history_collects_assistant_text_and_tool_results_in_order() {
|
||||||
let history = vec![
|
let history = vec![
|
||||||
Message::assistant("Checking sources..."),
|
Message::assistant("Checking sources..."),
|
||||||
Message::tool_result("call-1", "Result A [1]"),
|
Message::tool_result("call-1", "call-1-name", "Result A [1]"),
|
||||||
Message::assistant("Cross-checking..."),
|
Message::assistant("Cross-checking..."),
|
||||||
Message::tool_result("call-2", "Result B [2]"),
|
Message::tool_result("call-2", "call-2-name", "Result B [2]"),
|
||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -233,11 +233,11 @@ mod tests {
|
||||||
fn transcript_lines_renders_tool_calls_with_their_arguments() {
|
fn transcript_lines_renders_tool_calls_with_their_arguments() {
|
||||||
let message = Message::Assistant {
|
let message = Message::Assistant {
|
||||||
id: None,
|
id: None,
|
||||||
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
content: vec![AssistantContent::tool_call(
|
||||||
"call-1",
|
"call-1",
|
||||||
"search_web",
|
"search_web",
|
||||||
serde_json::json!({ "query": "uruguay senior engineers" }),
|
serde_json::json!({ "query": "uruguay senior engineers" }),
|
||||||
)),
|
)],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
transcript_lines(&message),
|
transcript_lines(&message),
|
||||||
|
|
@ -247,7 +247,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transcript_lines_prefixes_and_truncates_tool_results() {
|
fn transcript_lines_prefixes_and_truncates_tool_results() {
|
||||||
let short = Message::tool_result("call-1", "found via search [1]");
|
let short = Message::tool_result("call-1", "call-1-name", "found via search [1]");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
transcript_lines(&short),
|
transcript_lines(&short),
|
||||||
vec!["Result: found via search [1]".to_string()]
|
vec!["Result: found via search [1]".to_string()]
|
||||||
|
|
@ -264,13 +264,17 @@ mod tests {
|
||||||
let history = vec![
|
let history = vec![
|
||||||
Message::Assistant {
|
Message::Assistant {
|
||||||
id: None,
|
id: None,
|
||||||
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
content: vec![AssistantContent::tool_call(
|
||||||
"call-1",
|
"call-1",
|
||||||
"search_web",
|
"search_web",
|
||||||
serde_json::json!({ "query": "test" }),
|
serde_json::json!({ "query": "test" }),
|
||||||
)),
|
)],
|
||||||
},
|
},
|
||||||
Message::tool_result("call-1", "1. Example\n https://example.com\n snippet"),
|
Message::tool_result(
|
||||||
|
"call-1",
|
||||||
|
"call-1-name",
|
||||||
|
"1. Example\n https://example.com\n snippet",
|
||||||
|
),
|
||||||
Message::assistant("that source looks solid"),
|
Message::assistant("that source looks solid"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod documents;
|
||||||
mod history;
|
mod history;
|
||||||
mod models;
|
mod models;
|
||||||
mod observability;
|
mod observability;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod research;
|
mod research;
|
||||||
mod researcher;
|
mod researcher;
|
||||||
|
mod retrieval;
|
||||||
mod review;
|
mod review;
|
||||||
mod stream;
|
mod stream;
|
||||||
mod summarizer;
|
mod summarizer;
|
||||||
|
|
@ -30,7 +32,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
// The report streams to stdout as the writer generates it, so nothing
|
// The report streams to stdout as the writer generates it, so nothing
|
||||||
// left to print here — the return value only matters to callers that
|
// left to print here — the return value only matters to callers that
|
||||||
// embed `research` rather than running it as this binary.
|
// embed `research` rather than running it as this binary.
|
||||||
research::research(&topic, show_progress).await?;
|
research::research(&topic, &cli.docs, show_progress).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,9 @@
|
||||||
/// passes instead.
|
/// passes instead.
|
||||||
pub(crate) const RESEARCHER_MODEL: &str = "gemma4:26b";
|
pub(crate) const RESEARCHER_MODEL: &str = "gemma4:26b";
|
||||||
pub(crate) const WRITER_MODEL: &str = "gemma4-e4b:latest";
|
pub(crate) const WRITER_MODEL: &str = "gemma4-e4b:latest";
|
||||||
|
|
||||||
|
/// Deliberately a dedicated embedding model rather than reusing a chat model
|
||||||
|
/// for embeddings — it's trained for semantic similarity, not chat, and
|
||||||
|
/// Ollama's `nomic-embed-text` is a well-known identifier Rig already knows
|
||||||
|
/// the output dimensionality for.
|
||||||
|
pub(crate) const EMBEDDING_MODEL: &str = "nomic-embed-text";
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub(crate) const REVIEW_EMOJI: &str = "🧐";
|
||||||
pub(crate) const REJECTED_EMOJI: &str = "❌";
|
pub(crate) const REJECTED_EMOJI: &str = "❌";
|
||||||
pub(crate) const REPORT_EMOJI: &str = "✍️";
|
pub(crate) const REPORT_EMOJI: &str = "✍️";
|
||||||
pub(crate) const SUMMARIZE_EMOJI: &str = "🧩";
|
pub(crate) const SUMMARIZE_EMOJI: &str = "🧩";
|
||||||
|
pub(crate) const EMBED_EMOJI: &str = "📚";
|
||||||
|
|
||||||
/// The spinner currently on screen, if any — set by `Spinner::start` and
|
/// The spinner currently on screen, if any — set by `Spinner::start` and
|
||||||
/// cleared on drop. Tool implementations don't otherwise have a handle to
|
/// cleared on drop. Tool implementations don't otherwise have a handle to
|
||||||
|
|
@ -68,6 +69,15 @@ impl Drop for Spinner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prints a status line, but only when progress display is on — matching
|
||||||
|
/// `Spinner`'s own `enabled` gate so a call site doesn't need to repeat the
|
||||||
|
/// `if show_progress` check inline.
|
||||||
|
pub(crate) fn report(enabled: bool, message: impl Into<String>) {
|
||||||
|
if enabled {
|
||||||
|
eprintln!("{}", message.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates the message of whatever spinner is currently running, if any.
|
/// Updates the message of whatever spinner is currently running, if any.
|
||||||
/// A no-op when progress display is off (no spinner was ever started, so
|
/// A no-op when progress display is off (no spinner was ever started, so
|
||||||
/// `active()` stays empty) or between phases (the previous `Spinner` has
|
/// `active()` stays empty) or between phases (the previous `Spinner` has
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
use crate::progress::REJECTED_EMOJI;
|
use crate::documents;
|
||||||
|
use crate::progress::{self, REJECTED_EMOJI};
|
||||||
use crate::researcher::gather_findings;
|
use crate::researcher::gather_findings;
|
||||||
|
use crate::retrieval;
|
||||||
use crate::review::{self, Review};
|
use crate::review::{self, Review};
|
||||||
use crate::writer::write_report;
|
use crate::writer::write_report;
|
||||||
use rig::client::Nothing;
|
use rig::client::Nothing;
|
||||||
use rig::providers::ollama;
|
use rig::providers::ollama;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Research/review rounds before giving up and writing the report from
|
/// Research/review rounds before giving up and writing the report from
|
||||||
/// whatever the last pass produced, rather than looping forever on a topic
|
/// whatever the last pass produced, rather than looping forever on a topic
|
||||||
|
|
@ -14,45 +17,82 @@ const MAX_RESEARCH_ROUNDS: usize = 3;
|
||||||
/// not a model, in charge of when to stop — re-running research with the
|
/// not a model, in charge of when to stop — re-running research with the
|
||||||
/// reviewer's feedback folded in until it approves or the round budget runs
|
/// reviewer's feedback folded in until it approves or the round budget runs
|
||||||
/// out, then writing the report from whatever the last pass produced.
|
/// out, then writing the report from whatever the last pass produced.
|
||||||
pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result<String> {
|
pub(crate) async fn research(
|
||||||
|
topic: &str,
|
||||||
|
docs: &[PathBuf],
|
||||||
|
show_progress: bool,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
let client = ollama::Client::new(Nothing)?;
|
let client = ollama::Client::new(Nothing)?;
|
||||||
|
|
||||||
|
let doc_context = build_doc_context(&client, topic, docs, show_progress).await?;
|
||||||
|
|
||||||
let mut findings = String::new();
|
let mut findings = String::new();
|
||||||
let mut feedback: Option<Review> = None;
|
let mut feedback: Option<Review> = None;
|
||||||
let mut incomplete = false;
|
let mut incomplete = false;
|
||||||
|
|
||||||
for round in 1..=MAX_RESEARCH_ROUNDS {
|
for round in 1..=MAX_RESEARCH_ROUNDS {
|
||||||
let gathered =
|
let gathered = gather_findings(
|
||||||
gather_findings(&client, topic, feedback.as_ref(), round, show_progress).await?;
|
&client,
|
||||||
|
topic,
|
||||||
|
feedback.as_ref(),
|
||||||
|
doc_context.as_deref(),
|
||||||
|
round,
|
||||||
|
show_progress,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
findings = gathered.findings;
|
findings = gathered.findings;
|
||||||
incomplete = gathered.incomplete;
|
incomplete = gathered.incomplete;
|
||||||
|
|
||||||
// The researcher ran out of turns mid-investigation rather than
|
// Prevent repeat iterations where researcher hits same dead end.
|
||||||
// concluding on its own — another round would just repeat the same
|
|
||||||
// dead end, so stop and write up whatever was gathered.
|
|
||||||
if incomplete {
|
if incomplete {
|
||||||
tracing::info!(round, "researcher exhausted its turn budget");
|
tracing::info!(round, "researcher exhausted its turn budget");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let review = review::review_findings(&client, topic, &findings, show_progress).await?;
|
let review = review::review_findings(&client, topic, &findings, show_progress).await?;
|
||||||
let approved = review.approved;
|
|
||||||
|
|
||||||
tracing::info!(round, approved, "review verdict");
|
tracing::info!(round, review.approved, "review verdict");
|
||||||
|
|
||||||
if approved || round == MAX_RESEARCH_ROUNDS {
|
if review.approved || round == MAX_RESEARCH_ROUNDS {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if show_progress {
|
progress::report(
|
||||||
eprintln!(
|
show_progress,
|
||||||
|
format!(
|
||||||
"{REJECTED_EMOJI} Findings rejected — revising for round {}...",
|
"{REJECTED_EMOJI} Findings rejected — revising for round {}...",
|
||||||
round + 1
|
round + 1
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
feedback = Some(review);
|
feedback = Some(review);
|
||||||
}
|
}
|
||||||
|
|
||||||
write_report(&client, topic, &findings, incomplete, show_progress).await
|
write_report(&client, topic, &findings, incomplete, show_progress).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embeds any uploaded documents and retrieves the excerpts most relevant to
|
||||||
|
/// `topic`, once, up front — nothing later in the loop changes which
|
||||||
|
/// excerpts are relevant, so there's no reason to repeat this per round.
|
||||||
|
/// `None` when no documents were given, or none of them yielded a usable
|
||||||
|
/// excerpt (an empty `docs` list is deliberately not an error).
|
||||||
|
async fn build_doc_context(
|
||||||
|
client: &ollama::Client,
|
||||||
|
topic: &str,
|
||||||
|
docs: &[PathBuf],
|
||||||
|
show_progress: bool,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
if docs.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let documents = documents::collect_documents(docs)?;
|
||||||
|
if documents.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = retrieval::build_index(client, documents, show_progress).await?;
|
||||||
|
let excerpts = retrieval::retrieve_relevant(&index, topic, show_progress).await?;
|
||||||
|
|
||||||
|
Ok((!excerpts.is_empty()).then_some(excerpts))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ pub(crate) struct GatheredFindings {
|
||||||
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
||||||
/// single unit in traces, distinct from the writing and review phases and
|
/// single unit in traces, distinct from the writing and review phases and
|
||||||
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
||||||
#[tracing::instrument(skip(client, feedback), fields(gen_ai.agent.name = "researcher"))]
|
#[tracing::instrument(skip(client, feedback, doc_context), fields(gen_ai.agent.name = "researcher"))]
|
||||||
pub(crate) async fn gather_findings(
|
pub(crate) async fn gather_findings(
|
||||||
client: &ollama::Client,
|
client: &ollama::Client,
|
||||||
topic: &str,
|
topic: &str,
|
||||||
feedback: Option<&Review>,
|
feedback: Option<&Review>,
|
||||||
|
doc_context: Option<&str>,
|
||||||
round: usize,
|
round: usize,
|
||||||
show_progress: bool,
|
show_progress: bool,
|
||||||
) -> anyhow::Result<GatheredFindings> {
|
) -> anyhow::Result<GatheredFindings> {
|
||||||
|
|
@ -68,6 +69,17 @@ pub(crate) async fn gather_findings(
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let task = match doc_context {
|
||||||
|
Some(context) => format!(
|
||||||
|
"{task}\n\n\
|
||||||
|
Relevant excerpts from documents the user uploaded — treat these as trusted primary \
|
||||||
|
sources alongside anything you find on the web, and cite them with the same \
|
||||||
|
footnote scheme (their Sources entry can just be the document path shown below):\n\
|
||||||
|
{context}"
|
||||||
|
),
|
||||||
|
None => task,
|
||||||
|
};
|
||||||
|
|
||||||
let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching..."));
|
let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching..."));
|
||||||
let run_result = researcher
|
let run_result = researcher
|
||||||
.runner(task)
|
.runner(task)
|
||||||
|
|
|
||||||
81
src/retrieval.rs
Normal file
81
src/retrieval.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use crate::documents::Document;
|
||||||
|
use crate::models::EMBEDDING_MODEL;
|
||||||
|
use crate::progress::{self, EMBED_EMOJI, Spinner};
|
||||||
|
use rig::client::EmbeddingsClient;
|
||||||
|
use rig::providers::ollama;
|
||||||
|
use rig::vector_store::VectorStoreIndex;
|
||||||
|
use rig::vector_store::in_memory_store::{InMemoryVectorIndex, InMemoryVectorStore};
|
||||||
|
use rig::vector_store::request::VectorSearchRequest;
|
||||||
|
|
||||||
|
/// Excerpts returned per retrieval query — enough to give the researcher
|
||||||
|
/// useful grounding without one query result dominating its context.
|
||||||
|
const TOP_N_EXCERPTS: u64 = 5;
|
||||||
|
|
||||||
|
pub(crate) type DocumentIndex = InMemoryVectorIndex<ollama::EmbeddingModel, Document>;
|
||||||
|
|
||||||
|
/// Embeds every collected document with the dedicated embedding model (see
|
||||||
|
/// `models::EMBEDDING_MODEL`) and builds a queryable in-memory vector index
|
||||||
|
/// from the result. Runs once per research invocation, up front — document
|
||||||
|
/// relevance to the topic doesn't change between research/review rounds, so
|
||||||
|
/// there's nothing to gain from re-embedding per round.
|
||||||
|
#[tracing::instrument(skip(client, documents), fields(gen_ai.agent.name = "embedder"))]
|
||||||
|
pub(crate) async fn build_index(
|
||||||
|
client: &ollama::Client,
|
||||||
|
documents: Vec<Document>,
|
||||||
|
show_progress: bool,
|
||||||
|
) -> anyhow::Result<DocumentIndex> {
|
||||||
|
let spinner = Spinner::start(
|
||||||
|
show_progress,
|
||||||
|
format!("{EMBED_EMOJI} Embedding {} document(s)...", documents.len()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let embedded = client
|
||||||
|
.embeddings::<Document>(EMBEDDING_MODEL)
|
||||||
|
.documents(documents)?
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
drop(spinner);
|
||||||
|
tracing::info!(count = embedded.len(), "documents embedded");
|
||||||
|
|
||||||
|
let model = client.embedding_model(EMBEDDING_MODEL);
|
||||||
|
Ok(InMemoryVectorStore::from_documents(embedded).index(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the excerpts most semantically relevant to `query` from the
|
||||||
|
/// index, formatted for inclusion in the researcher's task.
|
||||||
|
#[tracing::instrument(skip(index), fields(gen_ai.agent.name = "retriever"))]
|
||||||
|
pub(crate) async fn retrieve_relevant(
|
||||||
|
index: &DocumentIndex,
|
||||||
|
query: &str,
|
||||||
|
show_progress: bool,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let request = VectorSearchRequest::builder()
|
||||||
|
.query(query)
|
||||||
|
.samples(TOP_N_EXCERPTS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let spinner = Spinner::start(
|
||||||
|
show_progress,
|
||||||
|
format!("{EMBED_EMOJI} Retrieving relevant excerpts..."),
|
||||||
|
);
|
||||||
|
let results = index.top_n::<Document>(request).await?;
|
||||||
|
drop(spinner);
|
||||||
|
|
||||||
|
progress::report(
|
||||||
|
show_progress,
|
||||||
|
format!(
|
||||||
|
"{EMBED_EMOJI} Retrieved {} relevant excerpt(s) from uploaded documents",
|
||||||
|
results.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tracing::info!(count = results.len(), "retrieved document excerpts");
|
||||||
|
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(score, _id, doc): (f64, String, Document)| {
|
||||||
|
format!("[{score:.2}] {}\n{}", doc.source, doc.text)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n"))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use rig::client::AgentClientExt;
|
use rig::client::AgentClientExt;
|
||||||
|
use rig::completion::TypedPrompt;
|
||||||
use rig::providers::ollama;
|
use rig::providers::ollama;
|
||||||
use rig::schemars::JsonSchema;
|
use rig::schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -25,10 +26,18 @@ pub(crate) struct Review {
|
||||||
pub(crate) gaps: String,
|
pub(crate) gaps: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uses rig's typed extractor — a forced tool call into a `submit(Review)`
|
/// Retries for a review that fails to come back as valid structured output
|
||||||
/// schema — rather than parsing free-text output, so the verdict and its
|
/// (e.g. a transient network error), on top of the initial attempt.
|
||||||
/// two feedback fields always come back structured instead of relying on
|
const REVIEW_RETRIES: usize = 2;
|
||||||
/// scanning prose for a trailing yes/no.
|
|
||||||
|
/// Uses rig's typed-prompt API — `Native` structured output constraining the
|
||||||
|
/// model's own reply to the `Review` schema — rather than a forced tool call
|
||||||
|
/// or parsing free-text output. A forced tool call needs `tool_choice`
|
||||||
|
/// support, which Ollama doesn't have: the reviewer's small local model would
|
||||||
|
/// just answer in prose and never call the tool, so extraction only ever
|
||||||
|
/// exhausted its retries and errored out. `Native` mode instead uses Ollama's
|
||||||
|
/// own `format` JSON-schema constraint, which is honored regardless of
|
||||||
|
/// tool-calling ability.
|
||||||
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "reviewer"))]
|
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "reviewer"))]
|
||||||
pub(crate) async fn review_findings(
|
pub(crate) async fn review_findings(
|
||||||
client: &ollama::Client,
|
client: &ollama::Client,
|
||||||
|
|
@ -37,7 +46,7 @@ pub(crate) async fn review_findings(
|
||||||
show_progress: bool,
|
show_progress: bool,
|
||||||
) -> anyhow::Result<Review> {
|
) -> anyhow::Result<Review> {
|
||||||
let reviewer = client
|
let reviewer = client
|
||||||
.extractor::<Review>(REVIEWER_MODEL)
|
.agent(REVIEWER_MODEL)
|
||||||
.preamble(
|
.preamble(
|
||||||
"You are a skeptical fact-checker reviewing another researcher's notes before they \
|
"You are a skeptical fact-checker reviewing another researcher's notes before they \
|
||||||
get turned into a report. Approve only if every conclusion in the findings is \
|
get turned into a report. Approve only if every conclusion in the findings is \
|
||||||
|
|
@ -47,20 +56,36 @@ pub(crate) async fn review_findings(
|
||||||
Always separate the solid, well-supported findings from the gaps so a follow-up \
|
Always separate the solid, well-supported findings from the gaps so a follow-up \
|
||||||
research pass knows what to keep and what to dig into further.",
|
research pass knows what to keep and what to dig into further.",
|
||||||
)
|
)
|
||||||
.retries(2)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let spinner = Spinner::start(
|
let spinner = Spinner::start(
|
||||||
show_progress,
|
show_progress,
|
||||||
format!("{REVIEW_EMOJI} Reviewing findings..."),
|
format!("{REVIEW_EMOJI} Reviewing findings..."),
|
||||||
);
|
);
|
||||||
let review = reviewer
|
|
||||||
.extract(format!(
|
let prompt = format!("Topic: {topic}\n\nResearch findings to review:\n{findings}");
|
||||||
"Topic: {topic}\n\nResearch findings to review:\n{findings}"
|
|
||||||
))
|
let mut last_error = None;
|
||||||
.await?;
|
let mut extracted_review = None;
|
||||||
|
|
||||||
|
for attempt in 0..=REVIEW_RETRIES {
|
||||||
|
match reviewer.prompt_typed(prompt.clone()).await {
|
||||||
|
Ok(r) => {
|
||||||
|
extracted_review = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Attempt {attempt} to extract JSON failed: {e:?}.");
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
drop(spinner);
|
drop(spinner);
|
||||||
|
|
||||||
|
let review: Review = extracted_review
|
||||||
|
.ok_or_else(|| last_error.expect("loop always sets last_error on failure"))?;
|
||||||
|
|
||||||
tracing::info!(approved = review.approved, gaps = %review.gaps, "review complete");
|
tracing::info!(approved = review.approved, gaps = %review.gaps, "review complete");
|
||||||
|
|
||||||
Ok(review)
|
Ok(review)
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,11 @@ use std::io::Write;
|
||||||
/// covering the gap between the prompt being sent and generation starting
|
/// covering the gap between the prompt being sent and generation starting
|
||||||
/// (otherwise the terminal would go blank for however long that takes)
|
/// (otherwise the terminal would go blank for however long that takes)
|
||||||
/// rather than being dropped by the caller before this is even called.
|
/// rather than being dropped by the caller before this is even called.
|
||||||
pub(crate) async fn write_text_stream<R>(
|
pub(crate) async fn write_text_stream(
|
||||||
mut stream: impl Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Unpin,
|
mut stream: impl Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + Unpin,
|
||||||
writer: &mut impl Write,
|
writer: &mut impl Write,
|
||||||
mut spinner: Spinner,
|
mut spinner: Spinner,
|
||||||
) -> anyhow::Result<String>
|
) -> anyhow::Result<String> {
|
||||||
where
|
|
||||||
R: Clone,
|
|
||||||
{
|
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
|
|
@ -50,10 +47,7 @@ mod tests {
|
||||||
use rig::completion::CompletionError;
|
use rig::completion::CompletionError;
|
||||||
use rig::message::Text;
|
use rig::message::Text;
|
||||||
|
|
||||||
#[derive(Clone)]
|
fn text_item(text: &str) -> Result<MultiTurnStreamItem, StreamingError> {
|
||||||
struct DummyResponse;
|
|
||||||
|
|
||||||
fn text_item(text: &str) -> Result<MultiTurnStreamItem<DummyResponse>, StreamingError> {
|
|
||||||
Ok(MultiTurnStreamItem::StreamAssistantItem(
|
Ok(MultiTurnStreamItem::StreamAssistantItem(
|
||||||
StreamedAssistantContent::Text(Text::new(text)),
|
StreamedAssistantContent::Text(Text::new(text)),
|
||||||
))
|
))
|
||||||
|
|
@ -76,7 +70,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ignores_non_text_items() {
|
async fn ignores_non_text_items() {
|
||||||
let final_item = Ok(MultiTurnStreamItem::final_response(
|
let final_item = Ok(MultiTurnStreamItem::final_response(
|
||||||
rig::OneOrMany::one(rig::message::AssistantContent::text("ignored")),
|
vec![rig::message::AssistantContent::text("ignored")],
|
||||||
rig::completion::Usage::new(),
|
rig::completion::Usage::new(),
|
||||||
));
|
));
|
||||||
let items = vec![text_item("kept"), final_item];
|
let items = vec![text_item("kept"), final_item];
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ pub(crate) async fn search_web(
|
||||||
) -> Result<String, ToolExecutionError> {
|
) -> Result<String, ToolExecutionError> {
|
||||||
progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}"));
|
progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}"));
|
||||||
|
|
||||||
|
// TODO re-use same client object?
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.get(format!("{}/search", searxng_base_url()))
|
.get(format!("{}/search", searxng_base_url()))
|
||||||
.query(&[("q", query.as_str()), ("format", "json")])
|
.query(&[("q", query.as_str()), ("format", "json")])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue