use std::time::Duration;
use tokio::time::timeout;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct JudgeInput {
pub conversation_id: String,
pub correlation_id: String,
pub content: String,
pub persona: String,
pub memory: String,
pub recent_messages: Vec<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct JudgeOutput {
pub speak: bool,
pub confidence: f32,
pub reason: Option<String>,
}
const DECISION_TIMEOUT_MS: u64 = 400;
const QUESTION_PATTERNS: &[&str] = &[
"how do",
"how can",
"how should",
"how would",
"what is",
"what are",
"what's",
"whats",
"why is",
"why are",
"why did",
"why does",
"who is",
"who are",
"who did",
"when is",
"when did",
"when will",
"where is",
"where did",
"can you",
"could you",
"would you",
"should i",
"should we",
"is there",
"are there",
"do you",
"does this",
"tell me",
"explain",
"help me",
"please",
"give me",
"need to",
"need help",
"looking for",
"어떻게",
"왜",
"뭐야",
"뭐예요",
"뭔가",
"누가",
"언제",
"어디",
"해줘",
"해줘요",
"해주세요",
"대답해",
"말해줘",
"알려줘",
"도와줘",
"설명해",
"좀 해",
"부탁해",
"도움",
"질문",
"대답",
"응답",
"안녕",
];
const LOW_ENGAGEMENT_PATTERNS: &[&str] = &[
"lol",
"lmao",
"haha",
"🤣",
"😂", "brb",
"afk",
"be right back",
"sent",
"delivered",
"typing", ];
const POSITIVE_PATTERNS: &[&str] = &[
"great",
"awesome",
"love it",
"perfect",
"thanks",
"nice work",
"well done",
"sounds good",
];
const COMPLEX_PATTERNS: &[&str] = &[
"however",
"although",
"but",
"alternatively",
"decision",
"recommend",
"strategy",
"architecture",
"refactor",
"migration",
"deployment",
"deploy",
"security",
"performance",
"optimization",
"error",
"bug",
"issue",
"fail",
"crash",
"api",
"endpoint",
"database",
"service",
"test",
"review",
"approve",
"reject",
"kubernetes",
"k8s",
"docker",
"container",
"helm",
"rust",
"golang",
"nodejs",
"python",
"java",
"microservice",
"serverless",
"ci/cd",
"pipeline",
];
const DEFAULT_ROLE_KEYWORDS: &[(&str, &[&str])] = &[
(
"pm",
&[
"plan",
"sprint",
"task",
"deadline",
"priority",
"roadmap",
"stakeholder",
"requirement",
],
),
(
"dev",
&[
"code",
"implement",
"bug",
"api",
"function",
"refactor",
"test",
"deploy",
"git",
],
),
(
"engineer",
&[
"code",
"implement",
"architecture",
"system",
"api",
"database",
"deploy",
],
),
(
"reviewer",
&[
"review",
"code review",
"feedback",
"approve",
"reject",
"improve",
"quality",
],
),
(
"critic",
&[
"concern",
"issue",
"risk",
"problem",
"think twice",
"reconsider",
"downside",
],
),
(
"designer",
&[
"design",
"ui",
"ux",
"interface",
"layout",
"visual",
"prototype",
"figma",
],
),
(
"researcher",
&[
"research",
"investigate",
"find",
"analyze",
"data",
"study",
"explore",
],
),
(
"tester",
&[
"test",
"bug",
"edge case",
"qa",
"coverage",
"failing",
"assertion",
"spec",
],
),
(
"ops",
&[
"deploy",
"infrastructure",
"docker",
"kubernetes",
"ci/cd",
"pipeline",
"monitor",
],
),
(
"analyst",
&[
"analyze",
"metric",
"data",
"insight",
"report",
"trend",
"query",
"dashboard",
],
),
];
pub async fn judge(input: JudgeInput) -> JudgeOutput {
let result = timeout(Duration::from_millis(DECISION_TIMEOUT_MS), async {
Ok::<JudgeOutput, ()>(heuristic_judge(&input))
})
.await;
match result {
Ok(Ok(output)) => output,
Ok(Err(())) => {
log::warn!("Judge panicked, returning conservative default");
JudgeOutput {
speak: false,
confidence: 0.0,
reason: Some("Judge panicked — conservative no-speak".to_string()),
}
}
Err(_) => {
log::warn!("Judge timed out after {}ms", DECISION_TIMEOUT_MS);
JudgeOutput {
speak: false,
confidence: 0.0,
reason: Some("Decision timeout".to_string()),
}
}
}
}
fn heuristic_judge(input: &JudgeInput) -> JudgeOutput {
let content_lower = input.content.to_lowercase();
let content = input.content.trim();
if is_noise(&content_lower, content) {
return JudgeOutput {
speak: false,
confidence: 0.05,
reason: Some("Low-engagement content (noise)".to_string()),
};
}
let role_keywords = extract_role_keywords(&input.persona);
let expertise_phrases = extract_expertise_phrases(&input.persona);
let role_score = score_role_relevance(&content_lower, &role_keywords, &expertise_phrases);
let engagement_score = score_engagement_signals(&content_lower, content);
let context_score = score_conversation_context(&input.recent_messages);
let combined = (role_score * 0.4) + (engagement_score * 0.4) + (context_score * 0.2);
let speak = combined > 0.05;
let confidence = combined.min(1.0);
let reason = build_reason(role_score, engagement_score, context_score, &role_keywords);
JudgeOutput {
speak,
confidence,
reason,
}
}
fn is_noise(content_lower: &str, content: &str) -> bool {
if content.is_empty() || content.trim().is_empty() {
return true;
}
if content.len() < 5 && !content.chars().any(|c| c.is_alphabetic()) {
return true;
}
for pattern in LOW_ENGAGEMENT_PATTERNS {
if content_lower.contains(pattern) {
return true;
}
}
false
}
fn extract_role_keywords(persona: &str) -> Vec<String> {
let mut keywords = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in persona.lines() {
let line_lower = line.to_lowercase();
if line_lower.starts_with("role:")
|| line_lower.starts_with("i am ")
|| line_lower.starts_with("i'm ")
|| line_lower.contains("my job is")
|| line_lower.contains("my focus is")
|| line_lower.starts_with("- role:")
{
let cleaned = line
.trim_start_matches(|c: char| c == '-' || c == ' ' || c == ':')
.trim_start_matches("role:")
.trim_start_matches("i am ")
.trim_start_matches("i'm ")
.trim_start_matches("my job is ")
.trim_start_matches("my focus is ");
for word in cleaned.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
let word = word.trim();
if word.len() > 2 && seen.insert(word.to_string()) {
keywords.push(word.to_string());
}
}
}
if line_lower.starts_with("expertise:")
|| line_lower.starts_with("skills:")
|| line_lower.starts_with("specialize")
|| line_lower.starts_with("knowledge")
{
for word in line.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '/') {
let word = word.trim();
if word.len() > 2 && seen.insert(word.to_string()) {
keywords.push(word.to_string());
}
}
}
}
keywords
}
fn extract_expertise_phrases(persona: &str) -> Vec<String> {
let mut phrases = Vec::new();
let mut seen = std::collections::HashSet::new();
let lines: Vec<&str> = persona.lines().collect();
for line in &lines {
let line_lower = line.to_lowercase();
if line_lower.starts_with("expertise:")
|| line_lower.starts_with("skills:")
|| line_lower.starts_with("expertiese:")
{
let content =
line.trim_start_matches(|c: char| !c.is_alphabetic() && c != '-' && c != '/');
let words: Vec<&str> = content.split_whitespace().collect();
for window in words.windows(2) {
let phrase = window.join(" ");
if seen.insert(phrase.clone()) {
phrases.push(phrase);
}
}
for window in words.windows(3) {
let phrase = window.join(" ");
if seen.insert(phrase.clone()) {
phrases.push(phrase);
}
}
}
}
phrases
}
fn score_role_relevance(
content_lower: &str,
role_keywords: &[String],
_expertise_phrases: &[String],
) -> f32 {
if role_keywords.is_empty() {
return score_with_default_keywords(content_lower);
}
let mut match_count = 0;
let _phrase_matches = 0;
for keyword in role_keywords {
if content_lower.contains(&keyword.to_lowercase()) {
match_count += 1;
}
}
let keyword_ratio = (match_count as f32 / (role_keywords.len() as f32).max(1.0)).min(1.0);
keyword_ratio * 0.7
}
fn score_with_default_keywords(content_lower: &str) -> f32 {
let mut best_score = 0.0_f32;
for (_role, keywords) in DEFAULT_ROLE_KEYWORDS {
let matches: usize = keywords
.iter()
.filter(|kw| content_lower.contains(&kw.to_lowercase()))
.count();
let ratio = (matches as f32 / (keywords.len() as f32).max(1.0)).min(1.0);
let score = ratio * 0.5; best_score = best_score.max(score);
}
best_score
}
fn score_engagement_signals(content_lower: &str, content: &str) -> f32 {
let mut score = 0.0_f32;
for pattern in QUESTION_PATTERNS {
if content_lower.contains(pattern) {
score += 0.35; }
}
if content.contains('?') && score == 0.0 {
score += 0.25;
}
if content_lower.starts_with("please ")
|| content_lower.starts_with("can you ")
|| content_lower.starts_with("could you ")
|| content_lower.starts_with("would you ")
|| content_lower.starts_with("tell ")
|| content_lower.starts_with("show ")
|| content_lower.starts_with("give ")
{
score += 0.2;
}
for pattern in POSITIVE_PATTERNS {
if content_lower.contains(pattern) {
score += 0.1;
}
}
for pattern in COMPLEX_PATTERNS {
if content_lower.contains(pattern) {
score += 0.05;
}
}
let word_count = content.split_whitespace().count();
if word_count >= 10 {
score += 0.05;
}
if word_count >= 30 {
score += 0.05;
}
score.min(1.0)
}
fn score_conversation_context(recent_messages: &[String]) -> f32 {
if recent_messages.is_empty() {
return 0.0;
}
let mut score = 0.0_f32;
let avg_len: usize =
recent_messages.iter().map(|m| m.len()).sum::<usize>() / recent_messages.len().max(1);
if avg_len > 20 {
score += 0.1;
}
for msg in recent_messages.iter().take(3) {
let msg_lower = msg.to_lowercase();
if msg_lower.contains("what about")
|| msg_lower.contains("let's ")
|| msg_lower.contains("we should")
|| msg_lower.contains("agree")
{
score += 0.05;
}
}
score.min(1.0)
}
fn build_reason(
role_score: f32,
engagement_score: f32,
context_score: f32,
_role_keywords: &[String],
) -> Option<String> {
let mut parts = Vec::new();
if role_score > 0.1 {
parts.push(format!(
"role_match={:.2}",
(role_score * 10.0).round() / 10.0
));
}
if engagement_score > 0.1 {
parts.push(format!(
"engagement={:.2}",
(engagement_score * 10.0).round() / 10.0
));
}
if context_score > 0.05 {
parts.push(format!(
"context={:.2}",
(context_score * 10.0).round() / 10.0
));
}
if parts.is_empty() {
None
} else {
Some(format!("scores[{}]", parts.join("|")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_input(content: &str, persona: &str) -> JudgeInput {
JudgeInput {
conversation_id: "conv_1".to_string(),
correlation_id: "req_1".to_string(),
content: content.to_string(),
persona: persona.to_string(),
memory: String::new(),
recent_messages: vec![],
}
}
#[test]
fn test_noise_rejection() {
assert!(!heuristic_judge(&make_input("😂", "")).speak);
assert!(!heuristic_judge(&make_input("lol", "")).speak);
assert!(!heuristic_judge(&make_input("brb", "")).speak);
}
#[test]
fn test_question_detection() {
let input = make_input(
"How do I deploy a Rust service to Kubernetes?",
"Role: ops engineer",
);
let output = heuristic_judge(&input);
assert!(output.speak, "expected speak=true, got false");
assert!(
output.confidence > 0.2,
"expected confidence > 0.2, got {:.3}",
output.confidence
);
}
#[test]
fn test_role_keyword_extraction() {
let persona =
"Role: garden expert\nI am a plant specialist\nExpertiese: vegetables, herbs, soil";
let keywords = extract_role_keywords(persona);
assert!(keywords.iter().any(|k| k.contains("expert")));
assert!(keywords.iter().any(|k| k.contains("plant")));
assert!(keywords.iter().any(|k| k.contains("specialist")));
}
#[test]
fn test_role_relevance_garden() {
let persona = "Role: garden expert\nI am a plant specialist";
let input = make_input("My tomato plants have yellow leaves", persona);
let output = heuristic_judge(&input);
assert!(output.speak);
}
#[test]
fn test_imperative_command() {
let input = make_input(
"Please review the PR for the new API endpoint",
"Role: reviewer",
);
let output = heuristic_judge(&input);
assert!(output.speak);
}
#[test]
fn test_complex_content() {
let persona = "Role: engineer";
let input = make_input(
"We need to refactor the database migration script before the security audit",
persona,
);
let output = heuristic_judge(&input);
assert!(output.confidence > 0.0);
}
#[tokio::test]
async fn test_judge_timeout() {
let input = make_input("Hello world", "Role: pm");
let output = judge(input).await;
assert!(output.confidence >= 0.0);
}
#[test]
fn test_multi_word_expertise_phrases() {
let persona = "Expertiese: code review, database design, system architecture";
let phrases = extract_expertise_phrases(persona);
assert!(phrases.iter().any(|p| p.contains("code review")));
assert!(phrases.iter().any(|p| p.contains("database design")));
}
#[test]
fn test_conservative_default_on_empty_persona() {
let input = make_input("What's for lunch?", "");
let output = heuristic_judge(&input);
assert!(output.confidence >= 0.0);
}
#[test]
fn test_engagement_score_imperative() {
let input_please = make_input("Please check the build logs", "Role: dev");
let input_plain = make_input("Build logs show an error", "Role: dev");
let score_please =
score_engagement_signals(&input_please.content.to_lowercase(), &input_please.content);
let score_plain =
score_engagement_signals(&input_plain.content.to_lowercase(), &input_plain.content);
assert!(score_please > score_plain);
}
}