1use std::path::PathBuf;
25use std::sync::Arc;
26
27use crate::error::SkillError;
28use crate::evaluator::{SkillEvaluationRequest, SkillEvaluator, SkillVerdict};
29use crate::generator::{SkillGenerationRequest, SkillGenerator};
30use crate::registry::SkillRegistry;
31
32static DOMAIN_KEYWORDS: &[(&str, &str)] = &[
37 ("rust", "rust"),
38 ("python", "python"),
39 ("docker", "docker"),
40 ("git", "git"),
41 ("sql", "sql"),
42 ("http", "http"),
43 ("kubernetes", "kubernetes"),
44 ("k8s", "kubernetes"),
45 ("typescript", "typescript"),
46 ("go", "go"),
47 ("golang", "go"),
48 ("terraform", "terraform"),
49 ("react", "react"),
50 ("postgres", "postgres"),
51 ("postgresql", "postgres"),
52 ("bash", "bash"),
53 ("shell", "bash"),
54 ("yaml", "yaml"),
55 ("json", "json"),
56 ("toml", "toml"),
57 ("grpc", "grpc"),
58 ("redis", "redis"),
59 ("kafka", "kafka"),
60 ("aws", "aws"),
61 ("gcp", "gcp"),
62 ("azure", "azure"),
63];
64
65#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct DomainLabel(pub String);
70
71impl DomainLabel {
72 #[must_use]
82 pub fn to_skill_name(&self) -> String {
83 format!("world-knowledge-{}", self.0)
84 }
85}
86
87pub struct ProactiveExplorer {
112 generator: SkillGenerator,
113 evaluator: Option<Arc<SkillEvaluator>>,
114 output_dir: PathBuf,
115 max_chars: usize,
116 timeout_ms: u64,
117 excluded_domains: Vec<String>,
118}
119
120impl ProactiveExplorer {
121 #[must_use]
130 pub fn new(
131 generator: SkillGenerator,
132 evaluator: Option<Arc<SkillEvaluator>>,
133 output_dir: PathBuf,
134 max_chars: usize,
135 timeout_ms: u64,
136 excluded_domains: Vec<String>,
137 ) -> Self {
138 Self {
139 generator,
140 evaluator,
141 output_dir,
142 max_chars,
143 timeout_ms,
144 excluded_domains,
145 }
146 }
147
148 #[must_use]
150 pub fn timeout_ms(&self) -> u64 {
151 self.timeout_ms
152 }
153
154 #[tracing::instrument(name = "core.proactive.classify", skip_all)]
159 pub fn classify(&self, query: &str) -> Option<DomainLabel> {
160 let lower = query.to_lowercase();
161 for token in lower.split_whitespace() {
162 let token = token.trim_end_matches(|c: char| !c.is_alphanumeric());
164 for &(keyword, domain) in DOMAIN_KEYWORDS {
165 if token == keyword {
166 return Some(DomainLabel(domain.to_string()));
167 }
168 }
169 }
170 None
171 }
172
173 #[must_use]
175 pub fn has_knowledge(&self, registry: &SkillRegistry, domain: &DomainLabel) -> bool {
176 let name = domain.to_skill_name();
177 registry.all_meta().iter().any(|m| m.name == name)
178 }
179
180 #[must_use]
182 pub fn is_excluded(&self, domain: &DomainLabel) -> bool {
183 self.excluded_domains.iter().any(|e| e == &domain.0)
184 }
185
186 #[tracing::instrument(name = "core.proactive.explore", skip_all, fields(domain = %domain.0))]
195 pub async fn explore(&self, domain: &DomainLabel) -> Result<(), SkillError> {
196 let description = format!(
197 "World-knowledge reference skill for {domain}. \
198 Provide concise, authoritative quick-reference information about {domain}: \
199 key commands, idioms, and best practices. Keep the body under {max_chars} characters.",
200 domain = domain.0,
201 max_chars = self.max_chars,
202 );
203
204 let req = SkillGenerationRequest {
205 description: description.clone(),
206 category: Some("dev".into()),
207 allowed_tools: vec![],
208 };
209
210 let skill = self.generator.generate(req).await?;
211
212 if let Some(ref evaluator) = self.evaluator {
214 let eval_req = SkillEvaluationRequest {
215 name: &skill.name,
216 description: &skill.meta.description,
217 body: &skill.content,
218 original_intent: &description,
219 };
220 match evaluator.evaluate(&eval_req).await? {
221 SkillVerdict::Accept(_) | SkillVerdict::AcceptOnEvalError(_) => {}
222 SkillVerdict::Reject { score: _, reason } => {
223 tracing::info!(
224 domain = %domain.0,
225 %reason,
226 "proactive skill rejected by evaluator — skipping write"
227 );
228 return Ok(());
229 }
230 }
231 }
232
233 let skill_dir = self.output_dir.join(&skill.name);
235 if skill_dir.exists() {
236 tracing::debug!(
237 domain = %domain.0,
238 skill = %skill.name,
239 "proactive skill already exists, skipping"
240 );
241 return Ok(());
242 }
243 tokio::fs::create_dir_all(&skill_dir).await?;
244 let skill_path = skill_dir.join("SKILL.md");
245 tokio::fs::write(&skill_path, &skill.content).await?;
246 tracing::info!(
247 domain = %domain.0,
248 skill = %skill.name,
249 path = %skill_path.display(),
250 "proactive skill written to disk"
251 );
252 Ok(())
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn classify_rust_query() {
262 let generator = SkillGenerator::new(
263 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
264 PathBuf::from("/tmp"),
265 );
266 let explorer = ProactiveExplorer::new(
267 generator,
268 None,
269 PathBuf::from("/tmp"),
270 8_000,
271 30_000,
272 vec![],
273 );
274
275 let label = explorer.classify("how do I use rust async");
276 assert_eq!(label, Some(DomainLabel("rust".into())));
277 }
278
279 #[test]
280 fn classify_returns_none_for_unknown_domain() {
281 let generator = SkillGenerator::new(
282 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
283 PathBuf::from("/tmp"),
284 );
285 let explorer = ProactiveExplorer::new(
286 generator,
287 None,
288 PathBuf::from("/tmp"),
289 8_000,
290 30_000,
291 vec![],
292 );
293
294 assert_eq!(explorer.classify("how are you today"), None);
295 }
296
297 #[test]
298 fn classify_docker_with_punctuation() {
299 let generator = SkillGenerator::new(
300 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
301 PathBuf::from("/tmp"),
302 );
303 let explorer = ProactiveExplorer::new(
304 generator,
305 None,
306 PathBuf::from("/tmp"),
307 8_000,
308 30_000,
309 vec![],
310 );
311
312 let label = explorer.classify("docker, how do I mount volumes?");
314 assert_eq!(label, Some(DomainLabel("docker".into())));
315 }
316
317 #[test]
318 fn is_excluded_matches_configured_domains() {
319 let generator = SkillGenerator::new(
320 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
321 PathBuf::from("/tmp"),
322 );
323 let explorer = ProactiveExplorer::new(
324 generator,
325 None,
326 PathBuf::from("/tmp"),
327 8_000,
328 30_000,
329 vec!["rust".into(), "go".into()],
330 );
331
332 assert!(explorer.is_excluded(&DomainLabel("rust".into())));
333 assert!(explorer.is_excluded(&DomainLabel("go".into())));
334 assert!(!explorer.is_excluded(&DomainLabel("python".into())));
335 }
336
337 #[test]
338 fn domain_label_to_skill_name() {
339 assert_eq!(
340 DomainLabel("rust".into()).to_skill_name(),
341 "world-knowledge-rust"
342 );
343 assert_eq!(
344 DomainLabel("kubernetes".into()).to_skill_name(),
345 "world-knowledge-kubernetes"
346 );
347 }
348
349 #[test]
350 fn has_knowledge_empty_registry() {
351 let registry = SkillRegistry::load(&[] as &[std::path::PathBuf]);
352 let generator = SkillGenerator::new(
353 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
354 PathBuf::from("/tmp"),
355 );
356 let explorer = ProactiveExplorer::new(
357 generator,
358 None,
359 PathBuf::from("/tmp"),
360 8_000,
361 30_000,
362 vec![],
363 );
364
365 assert!(!explorer.has_knowledge(®istry, &DomainLabel("rust".into())));
366 }
367}