|
| 1 | +using System.Text.RegularExpressions; |
| 2 | + |
1 | 3 | namespace Taskdeck.Application.Services; |
2 | 4 |
|
3 | 5 | public static class LlmIntentClassifier |
4 | 6 | { |
| 7 | + // Timeout to prevent catastrophic backtracking |
| 8 | + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); |
| 9 | + |
| 10 | + // Negative context patterns — suppress matches in these contexts |
| 11 | + private static readonly Regex NegationPattern = new( |
| 12 | + @"\b(don'?t|do not|never|stop|cancel|undo|avoid)\b(\s+\w+){0,6}\s+\b(create|add|make|move|archive|delete|remove|update|edit|rename|generate|build|set up|prepare)\b", |
| 13 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 14 | + RegexTimeout); |
| 15 | + |
| 16 | + private static readonly Regex OtherToolPattern = new( |
| 17 | + @"\b(in|for|with|using|on)\s+(jira|trello|asana|notion|monday|clickup|linear|github issues|azure devops)\b", |
| 18 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 19 | + RegexTimeout); |
| 20 | + |
| 21 | + private static readonly Regex QuestionAboutHowPattern = new( |
| 22 | + @"^\s*(how|what|where|when|why|can\s+i|is\s+it)\b.*\?$", |
| 23 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 24 | + RegexTimeout); |
| 25 | + |
| 26 | + // Card creation — verbs followed by optional words then card/task nouns |
| 27 | + private static readonly Regex CardCreatePattern = new( |
| 28 | + @"\b(create|add|make|generate|build|prepare|set\s+up)\b(\s+\w+){0,5}\s+\b(cards?|tasks?|items?)\b", |
| 29 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 30 | + RegexTimeout); |
| 31 | + |
| 32 | + // "new card/task" with optional words between |
| 33 | + private static readonly Regex NewCardPattern = new( |
| 34 | + @"\b(new)\b(\s+\w+){0,4}\s+\b(cards?|tasks?|items?)\b", |
| 35 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 36 | + RegexTimeout); |
| 37 | + |
| 38 | + // Card move — "move" + optional words + "card/task" |
| 39 | + private static readonly Regex CardMovePattern = new( |
| 40 | + @"\bmove\b(\s+\w+){0,4}\s+\b(cards?|tasks?)\b", |
| 41 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 42 | + RegexTimeout); |
| 43 | + |
| 44 | + // Card archive — "archive/delete/remove" + optional words + "card/task" |
| 45 | + private static readonly Regex CardArchivePattern = new( |
| 46 | + @"\b(archive|delete|remove)\b(\s+\w+){0,4}\s+\b(cards?|tasks?)\b", |
| 47 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 48 | + RegexTimeout); |
| 49 | + |
| 50 | + // Card update — "update/edit/rename" + optional words + "card/task" |
| 51 | + private static readonly Regex CardUpdatePattern = new( |
| 52 | + @"\b(update|edit|rename|modify|change)\b(\s+\w+){0,4}\s+\b(cards?|tasks?)\b", |
| 53 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 54 | + RegexTimeout); |
| 55 | + |
| 56 | + // Board creation — verbs + optional words + "board" |
| 57 | + private static readonly Regex BoardCreatePattern = new( |
| 58 | + @"\b(create|add|make|generate|build|prepare|set\s+up|new)\b(\s+\w+){0,4}\s+\b(boards?|project\s+boards?)\b", |
| 59 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 60 | + RegexTimeout); |
| 61 | + |
| 62 | + // Board rename |
| 63 | + private static readonly Regex BoardRenamePattern = new( |
| 64 | + @"\b(rename|update|edit)\b(\s+\w+){0,4}\s+\bboards?\b", |
| 65 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 66 | + RegexTimeout); |
| 67 | + |
| 68 | + // Column reorder / sort |
| 69 | + private static readonly Regex ReorderPattern = new( |
| 70 | + @"\b(reorder|sort|rearrange|reorganize)\b(\s+\w+){0,4}\s+\b(cards?|columns?|boards?)\b", |
| 71 | + RegexOptions.Compiled | RegexOptions.IgnoreCase, |
| 72 | + RegexTimeout); |
| 73 | + |
5 | 74 | public static (bool IsActionable, string? ActionIntent) Classify(string message) |
6 | 75 | { |
7 | 76 | if (string.IsNullOrWhiteSpace(message)) |
8 | 77 | return (false, null); |
9 | 78 |
|
10 | 79 | var lower = message.ToLowerInvariant(); |
11 | 80 |
|
12 | | - // Card creation — explicit commands and natural language |
13 | | - if (lower.Contains("create card") || lower.Contains("add card") |
14 | | - || lower.Contains("create a card") || lower.Contains("add a card") |
15 | | - || lower.Contains("create task") || lower.Contains("add task") |
16 | | - || lower.Contains("create a task") || lower.Contains("add a task") |
17 | | - || lower.Contains("new card") || lower.Contains("new task") |
18 | | - || lower.Contains("make a card") || lower.Contains("make a task") |
19 | | - || lower.Contains("make card") || lower.Contains("make task")) |
20 | | - return (true, "card.create"); |
| 81 | + // Check negative context first — suppress if negated or about another tool |
| 82 | + if (IsNegativeContext(lower, message)) |
| 83 | + return (false, null); |
21 | 84 |
|
22 | | - if (lower.Contains("move card")) |
23 | | - return (true, "card.move"); |
24 | | - if (lower.Contains("archive card") || lower.Contains("delete card") |
25 | | - || lower.Contains("remove card")) |
| 85 | + // Archive/delete/remove must be checked BEFORE move to fix the |
| 86 | + // "remove card" substring bug (remove contains "move") |
| 87 | + if (MatchesCardArchive(lower)) |
26 | 88 | return (true, "card.archive"); |
27 | | - if (lower.Contains("update card") || lower.Contains("edit card") |
28 | | - || lower.Contains("rename card")) |
| 89 | + |
| 90 | + if (MatchesCardMove(lower)) |
| 91 | + return (true, "card.move"); |
| 92 | + |
| 93 | + if (MatchesCardUpdate(lower)) |
29 | 94 | return (true, "card.update"); |
30 | | - if (lower.Contains("create board") || lower.Contains("add board") |
31 | | - || lower.Contains("new board")) |
| 95 | + |
| 96 | + if (MatchesCardCreate(lower)) |
| 97 | + return (true, "card.create"); |
| 98 | + |
| 99 | + if (MatchesBoardCreate(lower)) |
32 | 100 | return (true, "board.create"); |
33 | | - if (lower.Contains("rename board")) |
| 101 | + |
| 102 | + if (MatchesBoardRename(lower)) |
34 | 103 | return (true, "board.update"); |
35 | | - if (lower.Contains("reorder cards") || lower.Contains("reorder column") |
36 | | - || lower.Contains("reorder columns") || lower.Contains("reorder board") |
37 | | - || lower.Contains("sort cards") || lower.Contains("sort column") |
38 | | - || lower.Contains("sort columns") || lower.Contains("sort board")) |
| 104 | + |
| 105 | + if (MatchesReorder(lower)) |
39 | 106 | return (true, "column.reorder"); |
40 | 107 |
|
41 | 108 | return (false, null); |
42 | 109 | } |
| 110 | + |
| 111 | + private static bool IsNegativeContext(string lower, string original) |
| 112 | + { |
| 113 | + try |
| 114 | + { |
| 115 | + // Negation: "don't create task yet" |
| 116 | + if (NegationPattern.IsMatch(lower)) |
| 117 | + return true; |
| 118 | + |
| 119 | + // Asking about another tool: "how do I create a card in Jira?" |
| 120 | + if (OtherToolPattern.IsMatch(lower) && QuestionAboutHowPattern.IsMatch(original.Trim())) |
| 121 | + return true; |
| 122 | + } |
| 123 | + catch (RegexMatchTimeoutException) |
| 124 | + { |
| 125 | + // On timeout, fall through to normal classification |
| 126 | + } |
| 127 | + |
| 128 | + return false; |
| 129 | + } |
| 130 | + |
| 131 | + private static bool MatchesCardCreate(string lower) |
| 132 | + { |
| 133 | + try |
| 134 | + { |
| 135 | + if (CardCreatePattern.IsMatch(lower)) |
| 136 | + return true; |
| 137 | + if (NewCardPattern.IsMatch(lower)) |
| 138 | + return true; |
| 139 | + } |
| 140 | + catch (RegexMatchTimeoutException) |
| 141 | + { |
| 142 | + // Fall through — don't match on timeout |
| 143 | + } |
| 144 | + |
| 145 | + return false; |
| 146 | + } |
| 147 | + |
| 148 | + private static bool MatchesCardMove(string lower) |
| 149 | + { |
| 150 | + try |
| 151 | + { |
| 152 | + if (CardMovePattern.IsMatch(lower)) |
| 153 | + return true; |
| 154 | + } |
| 155 | + catch (RegexMatchTimeoutException) { } |
| 156 | + |
| 157 | + return false; |
| 158 | + } |
| 159 | + |
| 160 | + private static bool MatchesCardArchive(string lower) |
| 161 | + { |
| 162 | + try |
| 163 | + { |
| 164 | + if (CardArchivePattern.IsMatch(lower)) |
| 165 | + return true; |
| 166 | + } |
| 167 | + catch (RegexMatchTimeoutException) { } |
| 168 | + |
| 169 | + return false; |
| 170 | + } |
| 171 | + |
| 172 | + private static bool MatchesCardUpdate(string lower) |
| 173 | + { |
| 174 | + try |
| 175 | + { |
| 176 | + if (CardUpdatePattern.IsMatch(lower)) |
| 177 | + return true; |
| 178 | + } |
| 179 | + catch (RegexMatchTimeoutException) { } |
| 180 | + |
| 181 | + return false; |
| 182 | + } |
| 183 | + |
| 184 | + private static bool MatchesBoardCreate(string lower) |
| 185 | + { |
| 186 | + try |
| 187 | + { |
| 188 | + if (BoardCreatePattern.IsMatch(lower)) |
| 189 | + return true; |
| 190 | + } |
| 191 | + catch (RegexMatchTimeoutException) { } |
| 192 | + |
| 193 | + return false; |
| 194 | + } |
| 195 | + |
| 196 | + private static bool MatchesBoardRename(string lower) |
| 197 | + { |
| 198 | + try |
| 199 | + { |
| 200 | + if (BoardRenamePattern.IsMatch(lower)) |
| 201 | + return true; |
| 202 | + } |
| 203 | + catch (RegexMatchTimeoutException) { } |
| 204 | + |
| 205 | + return false; |
| 206 | + } |
| 207 | + |
| 208 | + private static bool MatchesReorder(string lower) |
| 209 | + { |
| 210 | + try |
| 211 | + { |
| 212 | + if (ReorderPattern.IsMatch(lower)) |
| 213 | + return true; |
| 214 | + } |
| 215 | + catch (RegexMatchTimeoutException) { } |
| 216 | + |
| 217 | + return false; |
| 218 | + } |
43 | 219 | } |
0 commit comments