Add registry-driven scene routing and multi-root skill loading so fault-details and 95598 scene skills can be triggered from natural language while still running through the browser-backed runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
7.2 KiB
Rust
243 lines
7.2 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::{Map, Value};
|
|
|
|
const STAGED_SCENE_ROOT: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DispatchMode {
|
|
DirectBrowser,
|
|
AgentBrowser,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SceneRegistryEntry {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub summary: String,
|
|
pub tags: Vec<String>,
|
|
pub inputs: Vec<String>,
|
|
pub outputs: Vec<String>,
|
|
pub skill_package: String,
|
|
pub skill_tool: String,
|
|
pub skill_artifact_type: String,
|
|
pub dispatch_mode: DispatchMode,
|
|
pub expected_domain: String,
|
|
pub aliases: Vec<String>,
|
|
pub default_args: Map<String, Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SceneMetadata {
|
|
id: String,
|
|
name: String,
|
|
summary: String,
|
|
#[serde(default)]
|
|
tags: Vec<String>,
|
|
#[serde(default)]
|
|
inputs: Vec<String>,
|
|
#[serde(default)]
|
|
outputs: Vec<String>,
|
|
skill: SceneSkillMetadata,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SceneSkillMetadata {
|
|
package: String,
|
|
tool: String,
|
|
artifact_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
struct SceneMatchScore {
|
|
matched_terms: usize,
|
|
longest_term: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct SceneMatchResult {
|
|
score: SceneMatchScore,
|
|
has_strong_phrase_hit: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SceneRuntimePolicy {
|
|
scene_id: &'static str,
|
|
dispatch_mode: DispatchMode,
|
|
expected_domain: &'static str,
|
|
aliases: &'static [&'static str],
|
|
}
|
|
|
|
const FIRST_SLICE_POLICIES: [SceneRuntimePolicy; 2] = [
|
|
SceneRuntimePolicy {
|
|
scene_id: "fault-details-report",
|
|
dispatch_mode: DispatchMode::DirectBrowser,
|
|
expected_domain: "sgcc.example.invalid",
|
|
aliases: &["故障明细", "故障明细报表", "导出故障明细"],
|
|
},
|
|
SceneRuntimePolicy {
|
|
scene_id: "95598-repair-city-dispatch",
|
|
dispatch_mode: DispatchMode::AgentBrowser,
|
|
expected_domain: "95598.example.invalid",
|
|
aliases: &["95598抢修市指", "市指抢修监测", "95598抢修队列", "95598抢修市指监测"],
|
|
},
|
|
];
|
|
|
|
pub fn load_first_slice_scene_registry() -> Vec<SceneRegistryEntry> {
|
|
load_scene_registry_from_root(Path::new(STAGED_SCENE_ROOT))
|
|
}
|
|
|
|
pub fn load_scene_registry_from_root(root: &Path) -> Vec<SceneRegistryEntry> {
|
|
let mut registry = Vec::new();
|
|
for policy in FIRST_SLICE_POLICIES {
|
|
if let Some(entry) = load_scene_entry(root, &policy) {
|
|
registry.push(entry);
|
|
}
|
|
}
|
|
registry
|
|
}
|
|
|
|
pub fn match_scene_instruction(instruction: &str) -> Option<SceneRegistryEntry> {
|
|
let registry = load_first_slice_scene_registry();
|
|
match_scene_instruction_in_registry(®istry, instruction)
|
|
}
|
|
|
|
pub fn match_scene_instruction_in_registry(
|
|
registry: &[SceneRegistryEntry],
|
|
instruction: &str,
|
|
) -> Option<SceneRegistryEntry> {
|
|
let normalized_instruction = normalize_for_match(instruction);
|
|
if normalized_instruction.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut best_match: Option<(SceneMatchResult, &SceneRegistryEntry)> = None;
|
|
let mut ambiguous = false;
|
|
let mut strong_phrase_hits = 0;
|
|
|
|
for entry in registry {
|
|
let Some(result) = score_scene_instruction(entry, &normalized_instruction) else {
|
|
continue;
|
|
};
|
|
if result.has_strong_phrase_hit {
|
|
strong_phrase_hits += 1;
|
|
}
|
|
|
|
match &best_match {
|
|
None => {
|
|
best_match = Some((result, entry));
|
|
ambiguous = false;
|
|
}
|
|
Some((current_result, _)) if result.score > current_result.score => {
|
|
best_match = Some((result, entry));
|
|
ambiguous = false;
|
|
}
|
|
Some((current_result, current_entry)) if result.score == current_result.score => {
|
|
if result.has_strong_phrase_hit && current_result.has_strong_phrase_hit {
|
|
if current_entry.id != entry.id {
|
|
ambiguous = true;
|
|
}
|
|
} else if result.has_strong_phrase_hit && !current_result.has_strong_phrase_hit {
|
|
best_match = Some((result, entry));
|
|
ambiguous = false;
|
|
} else if current_result.has_strong_phrase_hit && !result.has_strong_phrase_hit {
|
|
ambiguous = false;
|
|
} else if current_entry.id != entry.id {
|
|
ambiguous = true;
|
|
}
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
|
|
if ambiguous || strong_phrase_hits > 1 {
|
|
None
|
|
} else {
|
|
best_match.map(|(_, entry)| entry.clone())
|
|
}
|
|
}
|
|
|
|
fn load_scene_entry(root: &Path, policy: &SceneRuntimePolicy) -> Option<SceneRegistryEntry> {
|
|
let scene_path = scene_json_path(root, policy.scene_id);
|
|
let contents = fs::read_to_string(scene_path).ok()?;
|
|
let metadata: SceneMetadata = serde_json::from_str(&contents).ok()?;
|
|
if metadata.id != policy.scene_id {
|
|
return None;
|
|
}
|
|
|
|
Some(SceneRegistryEntry {
|
|
id: policy.scene_id.to_string(),
|
|
name: metadata.name,
|
|
summary: metadata.summary,
|
|
tags: metadata.tags,
|
|
inputs: metadata.inputs,
|
|
outputs: metadata.outputs,
|
|
skill_package: metadata.skill.package,
|
|
skill_tool: metadata.skill.tool,
|
|
skill_artifact_type: metadata.skill.artifact_type,
|
|
dispatch_mode: policy.dispatch_mode,
|
|
expected_domain: policy.expected_domain.to_string(),
|
|
aliases: policy.aliases.iter().map(|alias| (*alias).to_string()).collect(),
|
|
default_args: Map::new(),
|
|
})
|
|
}
|
|
|
|
fn scene_json_path(root: &Path, scene_id: &str) -> PathBuf {
|
|
root.join("scenes").join(scene_id).join("scene.json")
|
|
}
|
|
|
|
fn score_scene_instruction(
|
|
entry: &SceneRegistryEntry,
|
|
normalized_instruction: &str,
|
|
) -> Option<SceneMatchResult> {
|
|
let mut matched_terms = 0;
|
|
let mut longest_term = 0;
|
|
let mut has_strong_phrase_hit = false;
|
|
|
|
for term in candidate_match_terms(entry) {
|
|
let normalized_term = normalize_for_match(&term);
|
|
if normalized_term.len() < 2 {
|
|
continue;
|
|
}
|
|
if normalized_instruction.contains(&normalized_term) {
|
|
matched_terms += 1;
|
|
longest_term = longest_term.max(normalized_term.len());
|
|
if normalized_term.len() >= 6 {
|
|
has_strong_phrase_hit = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if matched_terms == 0 {
|
|
None
|
|
} else {
|
|
Some(SceneMatchResult {
|
|
score: SceneMatchScore {
|
|
matched_terms,
|
|
longest_term,
|
|
},
|
|
has_strong_phrase_hit,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn candidate_match_terms(entry: &SceneRegistryEntry) -> Vec<String> {
|
|
let mut terms = Vec::new();
|
|
terms.push(entry.id.clone());
|
|
terms.push(entry.name.clone());
|
|
terms.push(entry.summary.clone());
|
|
terms.extend(entry.tags.iter().cloned());
|
|
terms.extend(entry.aliases.iter().cloned());
|
|
terms
|
|
}
|
|
|
|
fn normalize_for_match(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != ':')
|
|
.flat_map(|ch| ch.to_lowercase())
|
|
.collect()
|
|
}
|