use crate::types::IntentType;
use crate::*;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
impl Resolver {
pub fn namespace_info(&self) -> NamespaceInfo {
NamespaceInfo {
name: self.namespace_name.clone(),
description: self.namespace_description.clone(),
default_threshold: self.namespace_default_threshold,
domain_descriptions: self.domain_descriptions.clone(),
}
}
pub fn update_namespace(&mut self, edit: NamespaceEdit) -> Result<(), Error> {
if let Some(n) = edit.name {
self.namespace_name = n;
}
if let Some(d) = edit.description {
self.namespace_description = d;
}
if let Some(t) = edit.default_threshold {
self.namespace_default_threshold = t.map(|t| t.max(0.0));
}
if let Some(dd) = edit.domain_descriptions {
self.domain_descriptions = dd;
}
Ok(())
}
pub fn resolve_threshold(&self, request_override: Option<f32>, fallback: f32) -> f32 {
request_override
.or(self.namespace_default_threshold)
.unwrap_or(fallback)
}
pub fn domain_description(&self, domain: &str) -> Option<&str> {
self.domain_descriptions.get(domain).map(|s| s.as_str())
}
pub fn set_domain_description(&mut self, domain: &str, desc: &str) {
self.domain_descriptions
.insert(domain.to_string(), desc.to_string());
}
pub fn remove_domain_description(&mut self, domain: &str) {
self.domain_descriptions.remove(domain);
}
pub fn load_from_dir(path: &Path) -> Result<Self, crate::Error> {
let mut router = Self::new();
if let Ok(json) = std::fs::read_to_string(path.join("_ns.json")) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
if let Some(name) = val.get("name").and_then(|d| d.as_str()) {
router.namespace_name = name.to_string();
}
if let Some(desc) = val.get("description").and_then(|d| d.as_str()) {
router.namespace_description = desc.to_string();
}
if let Some(t) = val.get("default_threshold").and_then(|t| t.as_f64()) {
router.namespace_default_threshold = Some(t as f32);
}
}
}
let base = crate::scoring::english_morphology_base();
for (from, edges) in base.edges {
for edge in edges {
let existing = router.l1.edges.entry(from.clone()).or_default();
if !existing.iter().any(|e| e.target == edge.target) {
existing.push(edge);
}
}
}
if let Ok(json) = std::fs::read_to_string(path.join("_l1.json")) {
if let Ok(g) = serde_json::from_str::<crate::scoring::LexicalGraph>(&json) {
for (from, edges) in g.edges {
let existing = router.l1.edges.entry(from).or_default();
for edge in edges {
if let Some(e) = existing.iter_mut().find(|e| e.target == edge.target) {
*e = edge; } else {
existing.push(edge);
}
}
}
}
}
let l2_preloaded = if let Ok(json) = std::fs::read_to_string(path.join("_l2.json")) {
if let Ok(ig) = serde_json::from_str::<crate::scoring::IntentIndex>(&json) {
router.l2 = ig;
true
} else {
false
}
} else {
false
};
let entries = std::fs::read_dir(path).map_err(|e| {
crate::Error::Persistence(format!("cannot read {}: {}", path.display(), e))
})?;
let mut domain_dirs: Vec<(String, PathBuf)> = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
let name = match p.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('_') {
continue;
}
if p.is_dir() {
domain_dirs.push((name, p));
} else if p.extension().map(|e| e == "json").unwrap_or(false) {
let stem = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
load_intent_file(&mut router, &p, &stem, l2_preloaded);
}
}
for (domain, domain_dir) in &domain_dirs {
if let Ok(json) = std::fs::read_to_string(domain_dir.join("_domain.json")) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
if let Some(desc) = val.get("description").and_then(|d| d.as_str()) {
router
.domain_descriptions
.insert(domain.clone(), desc.to_string());
}
}
}
if let Ok(sub_entries) = std::fs::read_dir(domain_dir) {
for sub_entry in sub_entries.flatten() {
let p = sub_entry.path();
let sub_name = match p.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if sub_name.starts_with('_') {
continue;
}
if p.extension().map(|e| e == "json").unwrap_or(false) {
let stem = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let intent_id = format!("{}:{}", domain, stem);
load_intent_file(&mut router, &p, &intent_id, l2_preloaded);
}
}
}
}
router.rebuild_l0();
router.l2.rebuild_idf();
Ok(router)
}
pub fn save_to_dir(&self, path: &Path) -> Result<(), crate::Error> {
std::fs::create_dir_all(path).map_err(|e| {
crate::Error::Persistence(format!("cannot create {}: {}", path.display(), e))
})?;
let mut ns_meta = serde_json::json!({
"name": self.namespace_name,
"description": self.namespace_description,
});
if let Some(t) = self.namespace_default_threshold {
ns_meta["default_threshold"] = serde_json::json!(t);
}
std::fs::write(
path.join("_ns.json"),
serde_json::to_string_pretty(&ns_meta).unwrap_or_default(),
)
.map_err(|e| crate::Error::Persistence(format!("cannot write _ns.json: {}", e)))?;
let mut written: HashSet<PathBuf> = HashSet::new();
written.insert(path.join("_ns.json"));
for (domain, desc) in &self.domain_descriptions {
let domain_dir = path.join(domain);
std::fs::create_dir_all(&domain_dir).map_err(|e| {
crate::Error::Persistence(format!("cannot create domain dir {}: {}", domain, e))
})?;
let meta = serde_json::json!({"description": desc});
let meta_path = domain_dir.join("_domain.json");
std::fs::write(
&meta_path,
serde_json::to_string_pretty(&meta).unwrap_or_default(),
)
.map_err(|e| {
crate::Error::Persistence(format!(
"cannot write _domain.json for {}: {}",
domain, e
))
})?;
written.insert(meta_path);
}
for intent_id in self.intent_ids() {
let (domain_opt, name) = split_intent_id(&intent_id);
let file_path = if let Some(domain) = domain_opt {
let domain_dir = path.join(domain);
std::fs::create_dir_all(&domain_dir).map_err(|e| {
crate::Error::Persistence(format!("cannot create domain dir: {}", e))
})?;
let meta_path = domain_dir.join("_domain.json");
if !written.contains(&meta_path) {
let desc = self
.domain_descriptions
.get(domain)
.cloned()
.unwrap_or_default();
let meta = serde_json::json!({"description": desc});
std::fs::write(
&meta_path,
serde_json::to_string_pretty(&meta).unwrap_or_default(),
)
.ok();
written.insert(meta_path);
}
domain_dir.join(format!("{}.json", name))
} else {
path.join(format!("{}.json", name))
};
let info = self.intent(&intent_id);
let intent_json = serde_json::json!({
"description": info.as_ref().map(|i| i.description.as_str()).unwrap_or(""),
"type": info.as_ref().map(|i| i.intent_type).unwrap_or(IntentType::Action),
"phrases": self.training_by_lang(&intent_id).cloned().unwrap_or_default(),
"instructions": info.as_ref().map(|i| i.instructions.as_str()).unwrap_or(""),
"persona": info.as_ref().map(|i| i.persona.as_str()).unwrap_or(""),
"guardrails": info.as_ref().map(|i| i.guardrails.clone()).unwrap_or_default(),
"source": info.as_ref().and_then(|i| i.source.clone()),
"target": info.as_ref().and_then(|i| i.target.clone()),
"schema": info.as_ref().and_then(|i| i.schema.clone()),
});
std::fs::write(
&file_path,
serde_json::to_string_pretty(&intent_json).unwrap_or_default(),
)
.map_err(|e| {
crate::Error::Persistence(format!("cannot write {}: {}", file_path.display(), e))
})?;
written.insert(file_path);
}
if let Ok(json) = serde_json::to_string_pretty(&self.l1) {
let l1_path = path.join("_l1.json");
std::fs::write(&l1_path, json)
.map_err(|e| crate::Error::Persistence(format!("cannot write _l1.json: {}", e)))?;
written.insert(l1_path);
}
if let Ok(json) = serde_json::to_string_pretty(&self.l2) {
let l2_path = path.join("_l2.json");
std::fs::write(&l2_path, json)
.map_err(|e| crate::Error::Persistence(format!("cannot write _l2.json: {}", e)))?;
written.insert(l2_path);
}
cleanup_stale(path, &written);
Ok(())
}
}
fn split_intent_id(id: &str) -> (Option<&str>, &str) {
if let Some(pos) = id.find(':') {
(Some(&id[..pos]), &id[pos + 1..])
} else {
(None, id)
}
}
fn load_intent_file(router: &mut Resolver, path: &Path, intent_id: &str, skip_indexing: bool) {
let json = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("cannot read {}: {}", path.display(), e);
return;
}
};
let val: serde_json::Value = match serde_json::from_str(&json) {
Ok(v) => v,
Err(e) => {
eprintln!("invalid JSON in {}: {}", path.display(), e);
return;
}
};
let phrases_by_lang: HashMap<String, Vec<String>> = val
.get("phrases")
.and_then(|p| serde_json::from_value(p.clone()).ok())
.unwrap_or_default();
if phrases_by_lang.is_empty() {
router
.training
.insert(intent_id.to_string(), HashMap::new());
router.version += 1;
} else if skip_indexing {
router
.training
.insert(intent_id.to_string(), phrases_by_lang);
router.version += 1;
} else {
let _ = router.add_intent(intent_id, phrases_by_lang);
}
let edit = crate::IntentEdit {
intent_type: val.get("type").and_then(|t| t.as_str()).map(|s| match s {
"context" => IntentType::Context,
_ => IntentType::Action,
}),
description: val
.get("description")
.and_then(|d| d.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
instructions: val
.get("instructions")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
persona: val
.get("persona")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
guardrails: val
.get("guardrails")
.and_then(|v| v.as_array())
.and_then(|rules| {
let r: Vec<String> = rules
.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect();
if r.is_empty() {
None
} else {
Some(r)
}
}),
source: val
.get("source")
.and_then(|v| serde_json::from_value::<IntentSource>(v.clone()).ok()),
target: val
.get("target")
.and_then(|v| serde_json::from_value::<IntentTarget>(v.clone()).ok()),
schema: val.get("schema").filter(|s| !s.is_null()).cloned(),
};
let _ = router.update_intent(intent_id, edit);
}
fn cleanup_stale(ns_dir: &Path, written: &HashSet<PathBuf>) {
let Ok(entries) = std::fs::read_dir(ns_dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('_') {
continue;
}
if p.is_file() && name.ends_with(".json") && !written.contains(&p) {
let _ = std::fs::remove_file(&p);
} else if p.is_dir() {
let Ok(sub_entries) = std::fs::read_dir(&p) else {
continue;
};
for sub_entry in sub_entries.flatten() {
let sp = sub_entry.path();
let sub_name = sp.file_name().and_then(|n| n.to_str()).unwrap_or("");
if sub_name.starts_with('_') {
continue;
}
if sp.is_file() && sub_name.ends_with(".json") && !written.contains(&sp) {
let _ = std::fs::remove_file(&sp);
}
}
}
}
}
#[cfg(test)]
mod tests {
use crate::Resolver;
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::path::PathBuf::from(format!("/tmp/microresolve_test_{}_{}", tag, nanos));
std::fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn default_threshold_starts_unset() {
let r = Resolver::new();
assert_eq!(r.namespace_info().default_threshold, None);
}
#[test]
fn set_default_threshold_persists_in_round_trip() {
let dir = tmp_dir("threshold_set");
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
name: Some("test".to_string()),
default_threshold: Some(Some(1.30)),
..Default::default()
})
.unwrap();
r.save_to_dir(&dir).unwrap();
let r2 = Resolver::load_from_dir(&dir).unwrap();
assert_eq!(r2.namespace_info().default_threshold, Some(1.30));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn unset_default_threshold_omitted_from_disk() {
let dir = tmp_dir("threshold_unset");
let r = Resolver::new();
r.save_to_dir(&dir).unwrap();
let r2 = Resolver::load_from_dir(&dir).unwrap();
assert_eq!(r2.namespace_info().default_threshold, None);
let json = std::fs::read_to_string(dir.join("_ns.json")).unwrap();
assert!(
!json.contains("default_threshold"),
"expected _ns.json to omit default_threshold when None, got: {}",
json
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn set_default_threshold_clamps_negative_to_zero() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(-5.0)),
..Default::default()
})
.unwrap();
assert_eq!(r.namespace_info().default_threshold, Some(0.0));
}
#[test]
fn clearing_default_threshold_via_none() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(0.7)),
..Default::default()
})
.unwrap();
assert_eq!(r.namespace_info().default_threshold, Some(0.7));
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(None),
..Default::default()
})
.unwrap();
assert_eq!(r.namespace_info().default_threshold, None);
}
#[test]
fn cascade_request_override_wins() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(1.30)),
..Default::default()
})
.unwrap();
assert_eq!(r.resolve_threshold(Some(0.5), 0.3), 0.5);
}
#[test]
fn cascade_namespace_default_used_when_no_request_override() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(1.30)),
..Default::default()
})
.unwrap();
assert_eq!(r.resolve_threshold(None, 0.3), 1.30);
}
#[test]
fn cascade_fallback_used_when_neither_set() {
let r = Resolver::new();
assert_eq!(r.resolve_threshold(None, 0.3), 0.3);
}
#[test]
fn cascade_request_zero_explicitly_wins_over_namespace() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(1.30)),
..Default::default()
})
.unwrap();
assert_eq!(r.resolve_threshold(Some(0.0), 0.3), 0.0);
}
#[test]
fn cascade_namespace_zero_wins_over_fallback() {
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(0.0)),
..Default::default()
})
.unwrap();
assert_eq!(r.resolve_threshold(None, 0.3), 0.0);
}
#[test]
fn explicit_zero_threshold_is_preserved_through_round_trip() {
let dir = tmp_dir("threshold_zero");
let mut r = Resolver::new();
r.update_namespace(crate::NamespaceEdit {
default_threshold: Some(Some(0.0)),
..Default::default()
})
.unwrap();
r.save_to_dir(&dir).unwrap();
let r2 = Resolver::load_from_dir(&dir).unwrap();
assert_eq!(r2.namespace_info().default_threshold, Some(0.0));
std::fs::remove_dir_all(&dir).ok();
}
}