Skip to main content

agent_doc_core/
heuristics.rs

1//! Recommendation-detection heuristics for pending-capture enforcement.
2
3#[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            // `pos` is a char boundary (keyword start), but `pos - 30` is a raw
157            // byte offset that can land inside a multibyte char (e.g. an em-dash
158            // in the response prose), which would panic the slice. Round the
159            // window start down to the nearest char boundary first.
160            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        // Regression: `pos - 30` is a raw byte offset that can land inside a
366        // multibyte char (em-dash). Place the em-dash so `pos - 30` falls in its
367        // middle byte, reproducing the panic that crashed session-check.
368        // 33 ASCII bytes, then "—" (bytes 33..36), 28 bytes, then " remaining"
369        // at byte 64 → pos-30 = 34, inside the em-dash.
370        let line = format!("{}—{} remaining tail", "x".repeat(33), "y".repeat(28));
371        // Must not panic; no qualifying non-zero digit precedes the keyword.
372        assert!(!has_quantified_remaining_work(&line));
373        // The true branch still works when a digit precedes the keyword, even
374        // with a multibyte char in the window.
375        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}