lean_ctx/core/knowledge_router/
mod.rs1pub mod context_bundle;
3pub mod gate_integration;
4pub mod manifest_builder;
5pub mod planner;
6pub mod provider_bridge;
7pub mod receipt;
8pub mod reference_resolver;
9pub mod source_manifest;
10
11#[cfg(test)]
12#[path = "knowledge_router_tests.rs"]
13mod knowledge_router_tests;
14
15use crate::core::task_spine::TaskProfileLocal;
16pub use context_bundle::{BundleStrategy, ContextBundle};
17pub use manifest_builder::{
18 ProviderConfig, build_manifests_for_provider_ids, build_manifests_from_config, detect_config,
19};
20pub use planner::{ContextCandidate, QueryPlanner};
21pub use provider_bridge::{ProviderBridge, ProviderResolution};
22pub use receipt::KnowledgeReceipt;
23pub use reference_resolver::{PatternReferenceResolver, ReferenceResolver, ResolvedReference};
24pub use source_manifest::{SourceManifestEntry, builtin_manifests};
25use std::sync::Arc;
26
27#[derive(Debug, Clone, Default)]
28pub struct KnowledgeRouter {
30 pub manifests: Vec<SourceManifestEntry>,
31 pub resolvers: Vec<Arc<dyn ReferenceResolver>>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct RoutingResult {
37 pub candidates: Vec<ContextCandidate>,
38 pub bundle: ContextBundle,
39 pub receipt: KnowledgeReceipt,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct ContextAdvice {
49 protected_references: Vec<String>,
50}
51
52impl ContextAdvice {
53 pub fn is_empty(&self) -> bool {
54 self.protected_references.is_empty()
55 }
56
57 pub fn protects(&self, content: &str) -> bool {
58 self.protected_references
59 .iter()
60 .any(|reference| content.contains(reference))
61 }
62}
63
64impl KnowledgeRouter {
65 pub fn route(
66 &self,
67 task_id: &str,
68 query: &str,
69 profile: &TaskProfileLocal,
70 providers: &[SourceManifestEntry],
71 bridge: Option<&ProviderBridge<'_>>,
72 ) -> RoutingResult {
73 let mut references = self
74 .resolvers
75 .iter()
76 .flat_map(|resolver| resolver.resolve(query))
77 .collect::<Vec<_>>();
78 let dynamic_manifests = bridge.map(|bridge| {
79 let config = detect_config();
80 let mut manifests = build_manifests_from_config(&config);
81 let configured = manifests
82 .iter()
83 .any(|manifest| manifest.source_id != "local_files");
84 if !configured {
85 manifests = builtin_manifests();
86 }
87 let available = bridge.available_provider_ids();
88 for manifest in build_manifests_for_provider_ids(&available) {
89 if !manifests
90 .iter()
91 .any(|existing| existing.source_id == manifest.source_id)
92 {
93 manifests.push(manifest);
94 }
95 }
96 let ids = available.iter().map(String::as_str).collect::<Vec<_>>();
97 let resolutions = bridge.resolve_from_providers(&references, &ids);
98 for (reference, resolution) in references.iter_mut().zip(resolutions) {
99 if resolution.resolved {
100 reference.source_id = resolution.provider_id;
101 } else {
102 reference.source_id.clear();
103 }
104 }
105 manifests
106 });
107 let manifests = if let Some(manifests) = dynamic_manifests.as_deref() {
108 manifests
109 } else if providers.is_empty() {
110 &self.manifests
111 } else {
112 providers
113 };
114 let budget = u64::from(profile.context_need_milli).max(250) * 4;
115 let candidates = QueryPlanner::plan(&references, manifests, budget);
116 let strategy = if profile.risk_signal == lean_ctx_protocol::RiskClass::Low {
117 BundleStrategy::Enriched
118 } else {
119 BundleStrategy::Governed
120 };
121 let bundle = context_bundle::create_bundle(task_id, &candidates, strategy);
122 let receipt = receipt::create_receipt(task_id, &bundle, &candidates, budget);
123 RoutingResult {
124 candidates,
125 bundle,
126 receipt,
127 }
128 }
129
130 pub fn context_advice(
135 &self,
136 task_id: &str,
137 query: &str,
138 profile: &TaskProfileLocal,
139 providers: &[SourceManifestEntry],
140 bridge: Option<&ProviderBridge<'_>>,
141 ) -> ContextAdvice {
142 let mut protected_references = self
143 .route(task_id, query, profile, providers, bridge)
144 .candidates
145 .into_iter()
146 .filter_map(|candidate| candidate.reference)
147 .filter(|reference| !reference.is_empty())
148 .collect::<Vec<_>>();
149 protected_references.sort();
150 protected_references.dedup();
151
152 ContextAdvice {
153 protected_references,
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::core::knowledge_router::reference_resolver::ReferenceType;
162 use crate::core::providers::registry::global_registry;
163 use crate::core::task_spine::TaskProfileLocal;
164
165 fn reference(ref_type: ReferenceType, identifier: &str) -> ResolvedReference {
166 ResolvedReference {
167 ref_type,
168 identifier: identifier.into(),
169 source_id: String::new(),
170 confidence_milli: 900,
171 }
172 }
173
174 #[test]
175 fn test_provider_bridge_jira() {
176 let result = ProviderBridge::new(global_registry())
177 .resolve_from_providers(&[reference(ReferenceType::JiraIssue, "LEAN-42")], &["jira"]);
178 assert_eq!(result[0].provider_id, "jira");
179 }
180
181 #[test]
182 fn test_provider_bridge_github() {
183 let result = ProviderBridge::new(global_registry())
184 .resolve_from_providers(&[reference(ReferenceType::GitHubPR, "#42")], &["github"]);
185 assert_eq!(result[0].estimated_tokens, 1_000);
186 }
187
188 #[test]
189 fn test_provider_bridge_no_provider() {
190 {
191 let found = &ProviderBridge::new(global_registry()).resolve_from_providers(
192 &[reference(ReferenceType::Url, "https://example.com")],
193 &[],
194 );
195 assert!(found.is_empty(), "{found:?}");
196 }
197 }
198
199 #[test]
200 fn test_manifest_builder_empty() {
201 assert_eq!(
202 build_manifests_from_config(&ProviderConfig::default()).len(),
203 1
204 );
205 }
206
207 #[test]
208 fn test_manifest_builder_full() {
209 let config = ProviderConfig {
210 jira_configured: true,
211 github_configured: true,
212 gitlab_configured: true,
213 postgres_configured: false,
214 custom_providers: vec![],
215 };
216 assert_eq!(build_manifests_from_config(&config).len(), 4);
217 }
218
219 #[test]
220 fn test_router_with_bridge() {
221 let router = KnowledgeRouter {
222 manifests: builtin_manifests(),
223 resolvers: vec![Arc::new(PatternReferenceResolver)],
224 };
225 let profile = TaskProfileLocal::default();
226 let bridge = ProviderBridge::new(global_registry());
227 let result = router.route(
228 "task",
229 "review src/core/knowledge_router/mod.rs",
230 &profile,
231 &[],
232 Some(&bridge),
233 );
234 assert!(
235 !result.candidates.is_empty(),
236 "`result.candidates` is empty"
237 );
238 }
239
240 #[test]
241 fn context_advice_preserves_router_reference() {
242 let router = KnowledgeRouter {
243 manifests: builtin_manifests(),
244 resolvers: vec![Arc::new(PatternReferenceResolver)],
245 };
246 let profile = TaskProfileLocal::default();
247 let advice = router.context_advice(
248 "task",
249 "review src/core/knowledge_router/mod.rs",
250 &profile,
251 &builtin_manifests(),
252 None,
253 );
254
255 assert!(advice.protects("Please review src/core/knowledge_router/mod.rs"));
256 }
257}