use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Respond,
Notify,
Ignore,
}
#[derive(Debug, Clone)]
pub struct ParticipationMsg {
pub speaker: String,
pub text: String,
pub is_self: bool,
}
fn system_prompt(bot_name: &str) -> String {
format!(
"You are {bot_name}, a participant in a multi-party Slack thread. Classify whether to \
engage with the LATEST message as exactly one of: respond, notify, ignore. Default to \
ignore. Choose respond only if you are directly addressed or are clearly the best party \
to help. Choose notify if the message is worth flagging to an operator but warrants no \
reply. If another human is already handling it, ignore. Answer with a single word: \
respond, notify, or ignore."
)
}
fn render_transcript(bot_name: &str, transcript: &[ParticipationMsg]) -> String {
let mut out = String::new();
for msg in transcript {
let speaker = if msg.is_self { bot_name } else { &msg.speaker };
out.push_str(speaker);
out.push_str(": ");
out.push_str(&msg.text);
out.push('\n');
}
out
}
fn parse_verdict(text: &str) -> Verdict {
let lower = text.to_lowercase();
if lower.contains("respond") {
Verdict::Respond
} else if lower.contains("notify") {
Verdict::Notify
} else {
Verdict::Ignore
}
}
pub async fn classify_participation<P: LlmProvider + ?Sized>(
provider: &P,
model: &str,
bot_name: &str,
transcript: &[ParticipationMsg],
) -> Result<Verdict, P::Error> {
let mut req = CompletionRequest::new(model);
req.messages.push(Message {
role: Role::System,
content: vec![Content::Text(system_prompt(bot_name))],
});
req.messages.push(Message {
role: Role::User,
content: vec![Content::Text(render_transcript(bot_name, transcript))],
});
let stream = provider.complete(req).await?;
let out = collect_turn(stream).await?;
Ok(parse_verdict(&out.text))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures::stream::{self, BoxStream, StreamExt};
use polyc_llm::{Chunk, StopReason, error::DummyError};
use super::*;
#[derive(Clone)]
struct MockProvider {
reply: String,
captured: Arc<Mutex<Option<CompletionRequest>>>,
}
impl MockProvider {
fn new(reply: &str) -> Self {
Self {
reply: reply.to_owned(),
captured: Arc::new(Mutex::new(None)),
}
}
}
#[async_trait]
impl LlmProvider for MockProvider {
type Error = DummyError;
async fn complete(
&self,
req: CompletionRequest,
) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
*self.captured.lock().unwrap() = Some(req);
let chunks = vec![
Ok(Chunk::text_delta(self.reply.clone())),
Ok(Chunk::Stop(StopReason::EndTurn)),
];
Ok(stream::iter(chunks).boxed())
}
}
fn sample_transcript() -> Vec<ParticipationMsg> {
vec![
ParticipationMsg {
speaker: "alice".to_owned(),
text: "can someone deploy the build?".to_owned(),
is_self: false,
},
ParticipationMsg {
speaker: "bot".to_owned(),
text: "on it".to_owned(),
is_self: true,
},
]
}
#[tokio::test]
async fn respond_reply_maps_to_respond() {
let provider = MockProvider::new("respond");
let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
assert_eq!(verdict, Verdict::Respond);
}
#[tokio::test]
async fn notify_reply_is_case_insensitive() {
let provider = MockProvider::new("NOTIFY please");
let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
assert_eq!(verdict, Verdict::Notify);
}
#[tokio::test]
async fn ignore_reply_maps_to_ignore() {
let provider = MockProvider::new("ignore");
let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
assert_eq!(verdict, Verdict::Ignore);
}
#[tokio::test]
async fn garbage_reply_defaults_to_ignore() {
let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
assert_eq!(verdict, Verdict::Ignore);
}
#[tokio::test]
async fn empty_reply_defaults_to_ignore() {
let provider = MockProvider::new("");
let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
assert_eq!(verdict, Verdict::Ignore);
}
#[tokio::test]
async fn request_carries_transcript_text() {
let provider = MockProvider::new("ignore");
let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
.await
.expect("classify");
let req = provider.captured.lock().unwrap().clone().expect("captured");
assert_eq!(req.messages.len(), 2);
assert_eq!(req.messages[0].role, Role::System);
assert_eq!(req.messages[1].role, Role::User);
let user_text = match &req.messages[1].content[0] {
Content::Text(t) => t.clone(),
other => panic!("expected text content, got {other:?}"),
};
assert!(user_text.contains("can someone deploy the build?"));
assert!(user_text.contains("bot: on it"));
let sys_text = match &req.messages[0].content[0] {
Content::Text(t) => t.clone(),
other => panic!("expected text content, got {other:?}"),
};
assert!(sys_text.contains("bot"));
assert!(sys_text.to_lowercase().contains("ignore"));
}
}