gitdataai/libs/agent/perception/mod.rs
ZhenYi 5c1b14c26a refactor(perception): simplify active/auto detection and deduplication
- Remove activation threshold logic from PassiveSkillAwareness
- Add SkillActivation enum with Priority/Keyword/Vector/Auto variants
- Add deduplication via SkillContext.dedupe_key() using rank ordering
- Simplify ActiveSkillAwareness with cleaner regex-based detection
2026-05-17 17:31:50 +08:00

191 lines
5.6 KiB
Rust

//! Skill perception system for the AI agent.
//!
//! Skills are injected through three modes:
//! - Active: explicit user invocation, highest priority.
//! - Passive: tool-call or event driven activation.
//! - Auto: ambient keyword relevance.
//!
//! Vector search is merged by the message builder as a semantic auto signal.
pub mod active;
pub mod auto;
pub mod passive;
pub mod vector;
pub use active::ActiveSkillAwareness;
pub use auto::AutoSkillAwareness;
pub use passive::PassiveSkillAwareness;
pub use vector::{VectorActiveAwareness, VectorPassiveAwareness};
use crate::client::ChatRequestMessage;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SkillActivation {
Active,
Passive,
Vector,
Auto,
}
impl SkillActivation {
fn label(self) -> &'static str {
match self {
SkillActivation::Active => "Active",
SkillActivation::Passive => "Passive",
SkillActivation::Vector => "Vector",
SkillActivation::Auto => "Auto",
}
}
pub fn rank(self) -> u8 {
match self {
SkillActivation::Active => 0,
SkillActivation::Passive => 1,
SkillActivation::Vector => 2,
SkillActivation::Auto => 3,
}
}
}
/// A chunk of skill context ready to be injected into the message list.
#[derive(Debug, Clone)]
pub struct SkillContext {
/// Stable skill identifier used for de-duplication across trigger sources.
pub slug: String,
/// Human-readable label shown to the AI, e.g. "Active skill: code-review"
pub label: String,
/// The actual skill content to inject.
pub content: String,
/// How this skill was selected.
pub activation: SkillActivation,
/// Optional relevance score. Active/passive matches use `None`.
pub score: Option<f32>,
}
/// Converts skill context into a system message for injection.
impl SkillContext {
pub fn new(
skill: &SkillEntry,
activation: SkillActivation,
reason: Option<&str>,
content: String,
score: Option<f32>,
) -> Self {
let label = match reason {
Some(reason) => format!("{} skill: {} ({})", activation.label(), skill.name, reason),
None => format!("{} skill: {}", activation.label(), skill.name),
};
Self {
slug: skill.slug.clone(),
label,
content,
activation,
score,
}
}
pub fn dedupe_key(&self) -> String {
if !self.slug.trim().is_empty() {
return normalize_skill_key(&self.slug);
}
normalize_skill_key(&self.label)
}
pub fn to_system_message(self) -> ChatRequestMessage {
ChatRequestMessage::system(format!("[{}]\n{}", self.label, self.content))
}
}
pub fn normalize_skill_key(value: &str) -> String {
value
.trim()
.to_lowercase()
.replace(['_', ' '], "-")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '/')
.collect()
}
/// Unified perception service combining all three modes.
#[derive(Debug, Clone)]
pub struct PerceptionService {
pub auto: AutoSkillAwareness,
pub active: ActiveSkillAwareness,
pub passive: PassiveSkillAwareness,
}
impl Default for PerceptionService {
fn default() -> Self {
Self {
auto: AutoSkillAwareness::default(),
active: ActiveSkillAwareness::default(),
passive: PassiveSkillAwareness::default(),
}
}
}
impl PerceptionService {
/// Inject relevant skill context into the message list based on current conversation state.
///
/// - **auto**: Scans the current input and conversation history for skill-relevant keywords
/// and injects matching skills that are enabled.
/// - **active**: Checks if the user explicitly invoked a skill by slug (e.g. "用 code-review")
/// and injects it.
/// - **passive**: Checks if any tool-call events or prior observations mention a skill
/// slug and injects the matching skill.
///
/// Returns a list of system messages to prepend to the conversation.
pub async fn inject_skills(
&self,
input: &str,
history: &[String],
tool_calls: &[ToolCallEvent],
enabled_skills: &[SkillEntry],
) -> Vec<SkillContext> {
let mut results = Vec::new();
let mut seen = HashSet::new();
// Active: explicit skill invocation (highest priority)
if let Some(skill) = self.active.detect(input, enabled_skills) {
seen.insert(skill.dedupe_key());
results.push(skill);
}
// Passive: triggered by tool-call events
for tc in tool_calls {
if let Some(skill) = self.passive.detect(tc, enabled_skills) {
if seen.insert(skill.dedupe_key()) {
results.push(skill);
}
}
}
// Auto: keyword-based relevance matching
let auto_results = self.auto.detect(input, history, enabled_skills).await;
for skill in auto_results {
if seen.insert(skill.dedupe_key()) {
results.push(skill);
}
}
results
}
}
/// A tool-call event used for passive skill detection.
#[derive(Debug, Clone)]
pub struct ToolCallEvent {
pub tool_name: String,
pub arguments: String,
}
/// A skill entry from the database, used for matching.
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub slug: String,
pub name: String,
pub description: Option<String>,
pub content: String,
}