1#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
4pub enum PatternKind {
5 PriorityLabel,
6 NumberedActionList,
7 RecommendationHeader,
8 ImperativeAfterRecommend,
9 UnconditionalFollowUp,
10}
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct RecommendationSignal {
14 pub estimated_count: usize,
15 pub patterns_matched: Vec<PatternKind>,
16 pub confidence: f32,
17}
18
19impl RecommendationSignal {
20 fn none() -> Self {
21 Self {
22 estimated_count: 0,
23 patterns_matched: Vec::new(),
24 confidence: 0.0,
25 }
26 }
27}
28
29const ACTION_VERBS: &[&str] = &[
30 "add",
31 "audit",
32 "cover",
33 "create",
34 "document",
35 "fix",
36 "implement",
37 "land",
38 "merge",
39 "migrate",
40 "refactor",
41 "remove",
42 "ship",
43 "test",
44 "update",
45 "write",
46];
47
48pub fn detect_uncaptured_recommendations(text: &str) -> RecommendationSignal {
49 let mut priority_count = 0usize;
50 let mut numbered_count = 0usize;
51 let mut imperative_after_recommend = 0usize;
52 let mut saw_header = false;
53 let mut recommend_window = 0usize;
54 let mut followup_count = 0usize;
55
56 for raw_line in text.lines() {
57 let trimmed = raw_line.trim();
58 if trimmed.is_empty() || trimmed.starts_with("<!--") {
59 recommend_window = recommend_window.saturating_sub(1);
60 continue;
61 }
62
63 if is_recommendation_header(trimmed) {
64 saw_header = true;
65 recommend_window = 5;
66 } else if contains_recommend_keyword(trimmed) {
67 recommend_window = 5;
68 }
69
70 if is_priority_label(trimmed) {
71 priority_count += 1;
72 recommend_window = 5;
73 }
74
75 if is_numbered_action(trimmed) {
76 numbered_count += 1;
77 } else if recommend_window > 0 && starts_with_action_verb(trimmed) {
78 imperative_after_recommend += 1;
79 }
80
81 if has_unconditional_followup_signal(trimmed) {
82 followup_count += 1;
83 }
84
85 recommend_window = recommend_window.saturating_sub(1);
86 }
87
88 if priority_count == 0
89 && numbered_count == 0
90 && imperative_after_recommend == 0
91 && followup_count == 0
92 && !saw_header
93 {
94 return RecommendationSignal::none();
95 }
96
97 let mut patterns = Vec::new();
98 let mut confidence = 0.0f32;
99
100 if priority_count > 0 {
101 patterns.push(PatternKind::PriorityLabel);
102 confidence = confidence.max(if priority_count >= 2 { 0.9 } else { 0.4 });
103 }
104 if numbered_count > 0 {
105 patterns.push(PatternKind::NumberedActionList);
106 confidence = confidence.max(if numbered_count >= 2 { 0.75 } else { 0.35 });
107 }
108 if saw_header {
109 patterns.push(PatternKind::RecommendationHeader);
110 confidence = confidence.max(0.2);
111 }
112 if imperative_after_recommend > 0 {
113 patterns.push(PatternKind::ImperativeAfterRecommend);
114 confidence = confidence.max(if imperative_after_recommend >= 2 {
115 0.65
116 } else {
117 0.3
118 });
119 }
120 if followup_count > 0 {
121 patterns.push(PatternKind::UnconditionalFollowUp);
122 confidence = confidence.max(if followup_count >= 2 { 0.85 } else { 0.7 });
123 }
124
125 if saw_header
126 && (priority_count >= 2
127 || numbered_count >= 2
128 || imperative_after_recommend >= 2
129 || followup_count >= 1)
130 {
131 confidence = confidence.max(0.85);
132 }
133
134 patterns.sort();
135 patterns.dedup();
136
137 RecommendationSignal {
138 estimated_count: priority_count
139 .max(numbered_count)
140 .max(imperative_after_recommend)
141 .max(followup_count),
142 patterns_matched: patterns,
143 confidence: confidence.min(1.0),
144 }
145}
146
147fn has_unconditional_followup_signal(line: &str) -> bool {
148 has_quantified_remaining_work(line) || has_unresolved_issue_followup(line)
149}
150
151fn has_quantified_remaining_work(line: &str) -> bool {
152 let lower = line.to_ascii_lowercase();
153 let keywords = [" remaining", " left to ", " outstanding", " unfinished"];
154 for kw in keywords {
155 if let Some(pos) = lower.find(kw) {
156 let start = lower.floor_char_boundary(pos.saturating_sub(30));
161 let prefix = &lower[start..pos];
162 if prefix.chars().any(|c| c.is_ascii_digit() && c != '0') {
163 return true;
164 }
165 }
166 }
167 false
168}
169
170fn has_unresolved_issue_followup(line: &str) -> bool {
171 let lower = line.to_ascii_lowercase();
172 if !contains_issue_or_followup_noun(&lower) {
173 return false;
174 }
175 if contains_resolved_marker(&lower) {
176 return false;
177 }
178 contains_unresolved_marker(&lower)
179}
180
181fn contains_issue_or_followup_noun(line: &str) -> bool {
182 const ISSUE_NOUNS: &[&str] = &[
183 " bug",
184 " issue",
185 " regression",
186 " failure",
187 " problem",
188 " gap",
189 " follow-up",
190 " follow up",
191 " backlog item",
192 " pending item",
193 " fix",
194 ];
195
196 ISSUE_NOUNS.iter().any(|marker| line.contains(marker))
197}
198
199fn contains_resolved_marker(line: &str) -> bool {
200 const RESOLVED_MARKERS: &[&str] = &[
201 "already fixed",
202 "was fixed",
203 "is fixed",
204 "fixed in ",
205 "resolved in ",
206 "no longer reproducible",
207 "closed by ",
208 ];
209
210 RESOLVED_MARKERS.iter().any(|marker| line.contains(marker))
211}
212
213fn contains_unresolved_marker(line: &str) -> bool {
214 const UNRESOLVED_MARKERS: &[&str] = &[
215 "still ",
216 " remains ",
217 " remain ",
218 " unresolved",
219 " still open",
220 " remains open",
221 " outstanding",
222 " needs ",
223 " need to ",
224 " should ",
225 " must ",
226 " meant to close",
227 " not fixed",
228 " not resolved",
229 " missing",
230 ];
231
232 UNRESOLVED_MARKERS
233 .iter()
234 .any(|marker| line.contains(marker))
235}
236
237fn is_priority_label(line: &str) -> bool {
238 let stripped = strip_leading_markers(line);
239 let lower = stripped.to_ascii_lowercase();
240
241 for prefix in ["p0:", "p1:", "p2:", "p3:"] {
242 if let Some(rest) = lower.strip_prefix(prefix) {
243 return starts_with_action_verb(rest);
244 }
245 }
246
247 for prefix in ["[p0]", "[p1]", "[p2]", "[p3]"] {
248 if let Some(rest) = lower.strip_prefix(prefix) {
249 return starts_with_action_verb(rest);
250 }
251 }
252
253 for prefix in ["priority 0", "priority 1", "priority 2", "priority 3"] {
254 if let Some(rest) = lower.strip_prefix(prefix) {
255 return starts_with_action_verb(rest.trim_start_matches([':', '-', ' ']));
256 }
257 }
258
259 false
260}
261
262fn is_numbered_action(line: &str) -> bool {
263 let stripped = line.trim_start();
264 let number_end = stripped
265 .find(|c: char| !c.is_ascii_digit())
266 .unwrap_or(stripped.len());
267 if number_end == 0 || number_end == stripped.len() {
268 return false;
269 }
270
271 let rest = &stripped[number_end..];
272 let Some(rest) = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')')) else {
273 return false;
274 };
275 starts_with_action_verb(rest)
276}
277
278fn is_recommendation_header(line: &str) -> bool {
279 let normalized = normalize_header_text(line);
280 matches!(
281 normalized.as_str(),
282 "recommendation"
283 | "recommendations"
284 | "next steps"
285 | "action items"
286 | "todo"
287 | "follow-up"
288 | "follow up"
289 )
290}
291
292fn contains_recommend_keyword(line: &str) -> bool {
293 let lower = line.to_ascii_lowercase();
294 lower.contains("recommend")
295 || lower.contains("next steps")
296 || lower.contains("action items")
297 || lower.contains("follow-up")
298 || lower.contains("follow up")
299}
300
301fn starts_with_action_verb(line: &str) -> bool {
302 let stripped = strip_leading_markers(line).trim();
303 let lower = stripped.to_ascii_lowercase();
304 let first = lower
305 .split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == ';')
306 .find(|part| !part.is_empty())
307 .unwrap_or("");
308 ACTION_VERBS.contains(&first)
309}
310
311fn strip_leading_markers(line: &str) -> &str {
312 let mut rest = line.trim_start();
313
314 loop {
315 let trimmed = rest.trim_start();
316 if let Some(next) = trimmed.strip_prefix('>') {
317 rest = next;
318 continue;
319 }
320 if let Some(next) = trimmed.strip_prefix("- [ ]") {
321 rest = next;
322 continue;
323 }
324 if let Some(next) = trimmed.strip_prefix("- [/]") {
325 rest = next;
326 continue;
327 }
328 if let Some(next) = trimmed
329 .strip_prefix("- [x]")
330 .or_else(|| trimmed.strip_prefix("- [X]"))
331 {
332 rest = next;
333 continue;
334 }
335 if let Some(next) = trimmed
336 .strip_prefix("- ")
337 .or_else(|| trimmed.strip_prefix("* "))
338 .or_else(|| trimmed.strip_prefix("+ "))
339 {
340 rest = next;
341 continue;
342 }
343 return trimmed
344 .trim_start_matches('*')
345 .trim_start_matches('#')
346 .trim();
347 }
348}
349
350fn normalize_header_text(line: &str) -> String {
351 let mut text = line.trim();
352 while let Some(next) = text.strip_prefix('#') {
353 text = next.trim_start();
354 }
355 text = text.trim_matches('*').trim();
356 text.trim_end_matches(':').to_ascii_lowercase()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn quantified_remaining_work_handles_multibyte_char_in_window() {
365 let line = format!("{}—{} remaining tail", "x".repeat(33), "y".repeat(28));
371 assert!(!has_quantified_remaining_work(&line));
373 let hit = format!("{}— 7 items remaining now", "x".repeat(33));
376 assert!(has_quantified_remaining_work(&hit));
377 }
378
379 #[test]
380 fn no_recommendations_no_signal() {
381 let signal = detect_uncaptured_recommendations("This is explanatory prose.\nNo follow-up.");
382 assert_eq!(signal.estimated_count, 0);
383 assert_eq!(signal.confidence, 0.0);
384 }
385
386 #[test]
387 fn numbered_recommendations_detected() {
388 let signal = detect_uncaptured_recommendations(
389 "## Recommendations\n1. Add regression coverage\n2. Fix the stale state update\n3. Update the spec\n",
390 );
391 assert_eq!(signal.estimated_count, 3);
392 assert!(signal.confidence >= 0.75);
393 assert!(
394 signal
395 .patterns_matched
396 .contains(&PatternKind::NumberedActionList)
397 );
398 }
399
400 #[test]
401 fn priority_labels_detected() {
402 let signal = detect_uncaptured_recommendations(
403 "P0: Fix scorer prompt\nP1: Add session-check coverage\n",
404 );
405 assert_eq!(signal.estimated_count, 2);
406 assert!(signal.confidence >= 0.9);
407 assert!(
408 signal
409 .patterns_matched
410 .contains(&PatternKind::PriorityLabel)
411 );
412 }
413
414 #[test]
415 fn imperative_after_recommend_context_detected() {
416 let signal = detect_uncaptured_recommendations(
417 "What I'd recommend:\n- Add a regression test\n- Update the command spec\n",
418 );
419 assert_eq!(signal.estimated_count, 2);
420 assert!(signal.confidence >= 0.65);
421 assert!(
422 signal
423 .patterns_matched
424 .contains(&PatternKind::ImperativeAfterRecommend)
425 );
426 }
427
428 #[test]
429 fn single_action_item_stays_low_confidence() {
430 let signal = detect_uncaptured_recommendations("Recommendation:\n1. Fix it.\n");
431 assert_eq!(signal.estimated_count, 1);
432 assert!(signal.confidence < 0.75);
433 }
434
435 #[test]
436 fn mutually_exclusive_options_do_not_look_like_recommendations() {
437 let signal = detect_uncaptured_recommendations(
438 "Options:\n1. Option A keeps the current behavior\n2. Option B defers the change\nChoose one.\n",
439 );
440 assert!(signal.estimated_count < 2 || signal.confidence < 0.5);
441 }
442
443 #[test]
444 fn unconditional_followup_with_quantified_remaining_work() {
445 let signal = detect_uncaptured_recommendations(
446 "Completed 5 of 23 diagrams. 18 remaining to transfer.\n\nOptions to continue:\n1. Retry with rate limiting\n2. Use manual upload\n3. Wait for quota reset\n",
447 );
448 assert!(signal.estimated_count >= 1);
449 assert!(signal.confidence >= 0.7);
450 assert!(
451 signal
452 .patterns_matched
453 .contains(&PatternKind::UnconditionalFollowUp)
454 );
455 }
456
457 #[test]
458 fn unconditional_followup_standalone_remaining() {
459 let signal = detect_uncaptured_recommendations(
460 "Transfer hit rate limit after 5 diagrams. 18 remaining items need processing.\n",
461 );
462 assert!(signal.estimated_count >= 1);
463 assert!(signal.confidence >= 0.7);
464 assert!(
465 signal
466 .patterns_matched
467 .contains(&PatternKind::UnconditionalFollowUp)
468 );
469 }
470
471 #[test]
472 fn zero_remaining_does_not_trigger_followup() {
473 let signal = detect_uncaptured_recommendations("All diagrams transferred. 0 remaining.\n");
474 assert!(
475 !signal
476 .patterns_matched
477 .contains(&PatternKind::UnconditionalFollowUp)
478 );
479 }
480
481 #[test]
482 fn quantified_left_to_triggers_followup() {
483 let signal = detect_uncaptured_recommendations(
484 "Processed 10 pages. 15 left to migrate before cutover.\n",
485 );
486 assert!(signal.estimated_count >= 1);
487 assert!(signal.confidence >= 0.7);
488 assert!(
489 signal
490 .patterns_matched
491 .contains(&PatternKind::UnconditionalFollowUp)
492 );
493 }
494
495 #[test]
496 fn options_without_remaining_work_no_followup() {
497 let signal = detect_uncaptured_recommendations(
498 "Here are your options:\n- Option A: keep current approach\n- Option B: switch to new API\n",
499 );
500 assert!(
501 !signal
502 .patterns_matched
503 .contains(&PatternKind::UnconditionalFollowUp)
504 );
505 }
506
507 #[test]
508 fn unresolved_bug_signal_triggers_followup() {
509 let signal = detect_uncaptured_recommendations(
510 "Because that session was still hitting the older tmux route/sync cleanup bug that #4qgx was meant to close.\n",
511 );
512 assert!(signal.estimated_count >= 1);
513 assert!(signal.confidence >= 0.7);
514 assert!(
515 signal
516 .patterns_matched
517 .contains(&PatternKind::UnconditionalFollowUp)
518 );
519 }
520
521 #[test]
522 fn resolved_bug_does_not_trigger_followup() {
523 let signal = detect_uncaptured_recommendations(
524 "The tmux cleanup bug was fixed in f4e646d and is no longer reproducible.\n",
525 );
526 assert!(
527 !signal
528 .patterns_matched
529 .contains(&PatternKind::UnconditionalFollowUp)
530 );
531 }
532}