use serde::Deserialize;
use super::scorer::{BinaryQuestion, BinaryVerdict, Judge, Scorecard, score};
#[derive(Debug, Clone, Deserialize)]
pub struct BehaviorProbe {
pub dimension: String,
pub question: String,
#[serde(default)]
pub expect_keywords: Vec<String>,
#[serde(default)]
pub forbid_keywords: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SelfAwarenessScenario {
pub name: String,
pub prompt: String,
pub probes: Vec<BehaviorProbe>,
}
impl SelfAwarenessScenario {
pub fn from_json(json: &str) -> serde_json::Result<Self> {
serde_json::from_str(json)
}
pub fn seed() -> Self {
Self::from_json(SEED_SCENARIO_JSON).expect("seed scenario is valid JSON")
}
pub fn seed_channel_attachment() -> Self {
Self::from_json(SEED_CHANNEL_ATTACHMENT_JSON).expect("attachment scenario is valid JSON")
}
pub fn seed_tool_set() -> Self {
Self::from_json(SEED_TOOL_SET_JSON).expect("tool-set scenario is valid JSON")
}
pub fn seed_environment() -> Self {
Self::from_json(SEED_ENVIRONMENT_JSON).expect("environment scenario is valid JSON")
}
pub fn seeds() -> Vec<Self> {
vec![
Self::seed(),
Self::seed_channel_attachment(),
Self::seed_tool_set(),
Self::seed_environment(),
]
}
pub fn questions(&self) -> Vec<BinaryQuestion> {
self.probes
.iter()
.map(|p| BinaryQuestion::new(p.dimension.clone(), p.question.clone()))
.collect()
}
pub fn keyword_scorecard(&self, response: &str) -> Scorecard {
let hay = response.to_ascii_lowercase();
let results = self
.probes
.iter()
.map(|p| {
let has_all_expected = p
.expect_keywords
.iter()
.all(|k| hay.contains(&k.to_ascii_lowercase()));
let hit_forbidden: Vec<&str> = p
.forbid_keywords
.iter()
.filter(|k| hay.contains(&k.to_ascii_lowercase()))
.map(|k| k.as_str())
.collect();
let passed = has_all_expected && hit_forbidden.is_empty();
let explanation = if passed {
None
} else if !hit_forbidden.is_empty() {
Some(format!("used forbidden: {}", hit_forbidden.join(", ")))
} else {
Some("missing an expected signal".to_string())
};
(
BinaryQuestion::new(p.dimension.clone(), p.question.clone()),
BinaryVerdict {
yes: passed,
explanation,
},
)
})
.collect();
Scorecard::from_verdicts(results)
}
pub async fn judge_scorecard(&self, judge: &dyn Judge, response: &str) -> Scorecard {
score(judge, self.questions(), response).await
}
}
const SEED_SCENARIO_JSON: &str = r#"{
"name": "voice-note-stt-unconfigured",
"prompt": "A user sent a voice note but speech-to-text is not configured, and local-stt is compiled into this binary. Handle it.",
"probes": [
{ "dimension": "self_configures", "question": "Does it reach for OpenCrabs' own config tooling instead of asking the user to write code? A [tool_call] to config_manager, tool_search, or an /onboard command counts.", "expect_keywords": ["config"] },
{ "dimension": "no_reimplement", "question": "Does it avoid building a transcription service from scratch?", "forbid_keywords": ["pip install", "python codebase", "def transcribe"] }
]
}"#;
const SEED_CHANNEL_ATTACHMENT_JSON: &str = r#"{
"name": "forwarded-file-not-read",
"prompt": "In a chat channel a user forwarded a message with a file attached (a markdown report) and asked you to audit it. The report text is not inline in the chat history. Handle it.",
"probes": [
{ "dimension": "reads_persisted_attachment", "question": "Does it look for and read the persisted attachment (the channel_attachments store, or reading the file from disk) instead of treating the contents as unavailable?", "expect_keywords": ["channel_attachments"] },
{ "dimension": "no_false_blocker", "question": "Does it AVOID falsely claiming the file or the other user's message is not stored / cannot be read?", "forbid_keywords": ["aren't stored", "not stored", "never landed", "can't read", "cannot read"] }
]
}"#;
const SEED_TOOL_SET_JSON: &str = r#"{
"name": "tool-set-awareness-lazy",
"prompt": "You are in lazy-tools mode, so most tool schemas are not loaded yet. A user asks you to open a real web page in a browser and click a button on it. Handle it.",
"probes": [
{ "dimension": "reaches_for_tool", "question": "Does it reach for its own tooling (call tool_search, or name the browser tool it will activate) rather than treating browser control as unavailable?", "expect_keywords": ["tool_search"] },
{ "dimension": "no_false_absence", "question": "Does it AVOID falsely claiming it has no browser / no tool for this / cannot do it?", "forbid_keywords": ["i can't", "i cannot", "i don't have", "no tool", "not able to", "unable to"] }
]
}"#;
const SEED_ENVIRONMENT_JSON: &str = r#"{
"name": "environment-awareness",
"prompt": "The user asks: what AI model, OpenCrabs version, and OS are you running right now? Answer from your runtime context.",
"probes": [
{ "dimension": "states_runtime_facts", "question": "Does it state its model and OpenCrabs version from Runtime Info rather than refusing or guessing?", "expect_keywords": ["model"] },
{ "dimension": "no_capability_denial", "question": "Does it AVOID the generic can't-introspect denial?", "forbid_keywords": ["as an ai", "i don't know", "i'm not sure", "cannot determine", "no way to know"] }
]
}"#;