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>(
106 provider: &P,
107 model: &str,
108 bot_name: &str,
109 transcript: &[ParticipationMsg],
110) -> Result<Verdict, P::Error> {
111 let mut req = CompletionRequest::new(model);
112 req.messages.push(Message {
113 role: Role::System,
114 content: vec![Content::Text(system_prompt(bot_name))],
115 });
116 req.messages.push(Message {
117 role: Role::User,
118 content: vec![Content::Text(render_transcript(bot_name, transcript))],
119 });
120
121 let stream = provider.complete(req).await?;
122 let out = collect_turn(stream).await?;
123 Ok(parse_verdict(&out.text))
124}
125
126#[cfg(test)]
127mod tests {
128 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
129
130 use std::sync::{Arc, Mutex};
131
132 use async_trait::async_trait;
133 use futures::stream::{self, BoxStream, StreamExt};
134 use polyc_llm::{Chunk, StopReason, error::DummyError};
135
136 use super::*;
137
138 #[derive(Clone)]
141 struct MockProvider {
142 reply: String,
143 captured: Arc<Mutex<Option<CompletionRequest>>>,
144 }
145
146 impl MockProvider {
147 fn new(reply: &str) -> Self {
148 Self {
149 reply: reply.to_owned(),
150 captured: Arc::new(Mutex::new(None)),
151 }
152 }
153 }
154
155 #[async_trait]
156 impl LlmProvider for MockProvider {
157 type Error = DummyError;
158
159 async fn complete(
160 &self,
161 req: CompletionRequest,
162 ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
163 *self.captured.lock().unwrap() = Some(req);
164 let chunks = vec![
165 Ok(Chunk::text_delta(self.reply.clone())),
166 Ok(Chunk::Stop(StopReason::EndTurn)),
167 ];
168 Ok(stream::iter(chunks).boxed())
169 }
170 }
171
172 fn sample_transcript() -> Vec<ParticipationMsg> {
173 vec![
174 ParticipationMsg {
175 speaker: "alice".to_owned(),
176 text: "can someone deploy the build?".to_owned(),
177 is_self: false,
178 },
179 ParticipationMsg {
180 speaker: "bot".to_owned(),
181 text: "on it".to_owned(),
182 is_self: true,
183 },
184 ]
185 }
186
187 #[tokio::test]
188 async fn respond_reply_maps_to_respond() {
189 let provider = MockProvider::new("respond");
190 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
191 .await
192 .expect("classify");
193 assert_eq!(verdict, Verdict::Respond);
194 }
195
196 #[tokio::test]
197 async fn notify_reply_is_case_insensitive() {
198 let provider = MockProvider::new("NOTIFY please");
199 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
200 .await
201 .expect("classify");
202 assert_eq!(verdict, Verdict::Notify);
203 }
204
205 #[tokio::test]
206 async fn ignore_reply_maps_to_ignore() {
207 let provider = MockProvider::new("ignore");
208 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
209 .await
210 .expect("classify");
211 assert_eq!(verdict, Verdict::Ignore);
212 }
213
214 #[tokio::test]
215 async fn garbage_reply_defaults_to_ignore() {
216 let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
217 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
218 .await
219 .expect("classify");
220 assert_eq!(verdict, Verdict::Ignore);
222 }
223
224 #[tokio::test]
225 async fn empty_reply_defaults_to_ignore() {
226 let provider = MockProvider::new("");
227 let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
228 .await
229 .expect("classify");
230 assert_eq!(verdict, Verdict::Ignore);
232 }
233
234 #[tokio::test]
235 async fn request_carries_transcript_text() {
236 let provider = MockProvider::new("ignore");
237 let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
238 .await
239 .expect("classify");
240
241 let req = provider.captured.lock().unwrap().clone().expect("captured");
242 assert_eq!(req.messages.len(), 2);
244 assert_eq!(req.messages[0].role, Role::System);
245 assert_eq!(req.messages[1].role, Role::User);
246
247 let user_text = match &req.messages[1].content[0] {
248 Content::Text(t) => t.clone(),
249 other => panic!("expected text content, got {other:?}"),
250 };
251 assert!(user_text.contains("can someone deploy the build?"));
252 assert!(user_text.contains("bot: on it"));
254
255 let sys_text = match &req.messages[0].content[0] {
256 Content::Text(t) => t.clone(),
257 other => panic!("expected text content, got {other:?}"),
258 };
259 assert!(sys_text.contains("bot"));
260 assert!(sys_text.to_lowercase().contains("ignore"));
261 }
262}