Files
claw/src/runtime/scene_registry.rs
木炎 96c3bf1dee feat: route staged scene skills through runtime
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>
2026-04-07 16:17:17 +08:00

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(&registry, 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()
}