1use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Verdict {
25 Respond,
28 Notify,
30 Ignore,
32}
33
34#[derive(Debug, Clone)]
36pub struct ParticipationMsg {
37 pub speaker: String,
39 pub text: String,
41 pub is_self: bool,
43}
44
45fn system_prompt(bot_name: &str) -> String {
47 format!(
48 "You are {bot_name}, a participant in a multi-party Slack thread. Classify whether to \
49 engage with the LATEST message as exactly one of: respond, notify, ignore. Default to \
50 ignore. Choose respond only if you are directly addressed or are clearly the best party \
51 to help. Choose notify if the message is worth flagging to an operator but warrants no \
52 reply. If another human is already handling it, ignore. Answer with a single word: \
53 respond, notify, or ignore."
54 )
55}
56
57fn render_transcript(bot_name: &str, transcript: &[ParticipationMsg]) -> String {
60 let mut out = String::new();
61 for msg in transcript {
62 let speaker = if msg.is_self { bot_name } else { &msg.speaker };
63 out.push_str(speaker);
64 out.push_str(": ");
65 out.push_str(&msg.text);
66 out.push('\n');
67 }
68 out
69}
70
71fn parse_verdict(text: &str) -> Verdict {
77 let lower = text.to_lowercase();
78 if lower.contains("respond") {
79 Verdict::Respond
80 } else if lower.contains("notify") {
81 Verdict::Notify
82 } else {
83 Verdict::Ignore
84 }
85}
86
87pub async fn classify_participation<P: LlmProvider + ?Sized>(
104 provider: &P,
105 model: &str,
106 bot_name: &str,
107 transcript: &[ParticipationMsg],
108) -> Result<Verdict, P::Error> {
109 let mut req = CompletionRequest::new(model);
110 req.messages.push(Message {
111 role: Role::System,
112 content: vec![Content::Text(system_prompt(bot_name))],
113 });
114 req.messages.push(Message {
115 role: Role::User,
116 content: vec![Content::Text(render_transcript(bot_name, transcript))],
117 });
118
119 let stream = provider.complete(req).await?;
120 let out = collect_turn(stream).await?;
121 Ok(parse_verdict(&out.text))
122}
123
124#[cfg(test)]
125mod tests {
126 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
127
128 use std::sync::{Arc, Mutex};
129
130 use async_trait::async_trait;
131 use futures::stream::{self, BoxStream, StreamExt};
132 use polyc_llm::{Chunk, StopReason, error::DummyError};
133
134 use super::*;
135
136 #[derive(Clone)]
139 struct MockProvider {
140 reply: String,
141 captured: Arc<Mutex<Option<CompletionRequest>>>,
142 }
143
144 impl MockProvider {
145 fn new(reply: &str) -> Self {
146 Self {
147 reply: reply.to_owned(),
148 captured: Arc::new(Mutex::new(None)),
149 }
150 }
151 }
152
153 #[async_trait]
154 impl LlmProvider for MockProvider {
155 type Error = DummyError;
156
157 async fn complete(
158 &self,
159 req: CompletionRequest,
160 ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
161 *self.captured.lock().unwrap() = Some(req);
162 let chunks = vec![
163 Ok(Chunk::text_delta(self.reply.clone())),
164 Ok(Chunk::Stop(StopReason::EndTurn)),
165 ];
166 Ok(stream::iter(chunks).boxed())
167 }
168 }
169
170 fn sample_transcript() -> Vec<ParticipationMsg> {
171 vec![
172 ParticipationMsg {
173 speaker: "alice".to_owned(),
174 text: "can someone deploy the build?".to_owned(),
175 is_self: false,
176 },
177 ParticipationMsg {
178 speaker: "bot".to_owned(),
179 text: "on it".to_owned(),
180 is_self: true,
181 },
182 ]
183 }
184
185 #[tokio::test]
186 async fn respond_reply_maps_to_respond() {
187 let provider = MockProvider::new("respond");
188 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
189 .await
190 .expect("classify");
191 assert_eq!(verdict, Verdict::Respond);
192 }
193
194 #[tokio::test]
195 async fn notify_reply_is_case_insensitive() {
196 let provider = MockProvider::new("NOTIFY please");
197 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
198 .await
199 .expect("classify");
200 assert_eq!(verdict, Verdict::Notify);
201 }
202
203 #[tokio::test]
204 async fn ignore_reply_maps_to_ignore() {
205 let provider = MockProvider::new("ignore");
206 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
207 .await
208 .expect("classify");
209 assert_eq!(verdict, Verdict::Ignore);
210 }
211
212 #[tokio::test]
213 async fn garbage_reply_defaults_to_ignore() {
214 let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
215 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
216 .await
217 .expect("classify");
218 assert_eq!(verdict, Verdict::Ignore);
220 }
221
222 #[tokio::test]
223 async fn empty_reply_defaults_to_ignore() {
224 let provider = MockProvider::new("");
225 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
226 .await
227 .expect("classify");
228 assert_eq!(verdict, Verdict::Ignore);
230 }
231
232 #[tokio::test]
233 async fn request_carries_transcript_text() {
234 let provider = MockProvider::new("ignore");
235 let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
236 .await
237 .expect("classify");
238
239 let req = provider.captured.lock().unwrap().clone().expect("captured");
240 assert_eq!(req.messages.len(), 2);
242 assert_eq!(req.messages[0].role, Role::System);
243 assert_eq!(req.messages[1].role, Role::User);
244
245 let user_text = match &req.messages[1].content[0] {
246 Content::Text(t) => t.clone(),
247 other => panic!("expected text content, got {other:?}"),
248 };
249 assert!(user_text.contains("can someone deploy the build?"));
250 assert!(user_text.contains("bot: on it"));
252
253 let sys_text = match &req.messages[0].content[0] {
254 Content::Text(t) => t.clone(),
255 other => panic!("expected text content, got {other:?}"),
256 };
257 assert!(sys_text.contains("bot"));
258 assert!(sys_text.to_lowercase().contains("ignore"));
259 }
260}