Skip to content

Commit a4fa86e

Browse files
authored
Merge pull request #393 from Resgrid/chatbot
Chatbot
2 parents c439d23 + c74752b commit a4fa86e

146 files changed

Lines changed: 14123 additions & 1843 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,44 @@
1212
"Bash(find G:ResgridResgridWebResgrid.WebAreasUserViews -type f -name *.cshtml)",
1313
"Bash(grep -r \"google.maps\\\\|mapboxgl\\\\|leaflet\\\\|openstreetmap\" G:/Resgrid/Resgrid/Web/Resgrid.Web/Areas/User/Apps/src --include=*.ts)",
1414
"Bash(grep -r \"leaflet\\\\|L\\\\.tileLayer\\\\|OpenStreetMap\" /g/Resgrid/Resgrid/Web/Resgrid.Web.Services --include=*.cs)",
15-
"Bash(find /g/Resgrid/Resgrid -type f -name *.swift -o -name *.kt -o -name *.java)"
15+
"Bash(find /g/Resgrid/Resgrid -type f -name *.swift -o -name *.kt -o -name *.java)",
16+
"mcp__graperoot-pro__graph_continue",
17+
"mcp__graperoot-pro__fallback_rg",
18+
"mcp__graperoot-pro__graph_read",
19+
"Bash(export PATH=\"$PATH:/usr/local/share/dotnet\")",
20+
"Read(//usr/local/**)",
21+
"Read(//opt/**)",
22+
"Bash(echo $PATH)",
23+
"Bash(command -v dotnet)",
24+
"Bash(brew --prefix dotnet)",
25+
"Bash(/opt/homebrew/opt/dotnet/bin/dotnet build:*)",
26+
"Bash(brew info:*)",
27+
"mcp__graperoot-pro__graph_register_edit"
1628
]
1729
},
30+
"enableAllProjectMcpServers": true,
31+
"enabledMcpjsonServers": [
32+
"graperoot-pro"
33+
],
1834
"hooks": {
19-
"SessionStart": [
35+
"PreToolUse": [
2036
{
21-
"matcher": "",
37+
"matcher": "Bash|Read",
2238
"hooks": [
2339
{
2440
"type": "command",
25-
"command": "powershell -NoProfile -File \"G:/Resgrid/Resgrid/.dual-graph/prime.ps1\""
41+
"command": "DG_DATA_DIR=\"/Volumes/USBSSD/dev/Resgrid/Core/.dual-graph-pro\" /Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/graph_gate.py\""
2642
}
2743
]
2844
}
2945
],
30-
"Stop": [
46+
"PostToolUse": [
3147
{
32-
"matcher": "",
48+
"matcher": "Write|Edit",
3349
"hooks": [
3450
{
3551
"type": "command",
36-
"command": "powershell -NoProfile -File \"G:/Resgrid/Resgrid/.dual-graph/stop_hook.ps1\""
37-
}
38-
]
39-
}
40-
],
41-
"PreCompact": [
42-
{
43-
"matcher": "",
44-
"hooks": [
45-
{
46-
"type": "command",
47-
"command": "powershell -NoProfile -File \"G:/Resgrid/Resgrid/.dual-graph/prime.ps1\""
52+
"command": "DG_DATA_DIR=\"/Volumes/USBSSD/dev/Resgrid/Core/.dual-graph-pro\" /Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/graph_sync.py\""
4853
}
4954
]
5055
}

.gitignore

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,7 @@ Web/Resgrid.WebCore/wwwroot/lib/*
273273
/Web/Resgrid.Web/wwwroot/lib
274274
.dual-graph/
275275
.claude/settings.local.json
276-
.claude/settings.local.json
277276
/Web/Resgrid.Web/wwwroot/js/ng/chunks
278277
opencode.json
279-
.claude/settings.local.json
280-
/.dual-graph-pro
281-
.claude/settings.local.json
278+
.dual-graph-pro/
282279
.mcp.json

CONTEXT.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# CONTEXT
2+
3+
**Current Task:** Implementing Phase 3 of the Resgrid chatbot (branch `chatbot`) — write/dispatch handlers, outbound delivery, localization, and new platform adapters. Build green; 71 chatbot unit tests passing. Remaining work is recorded in the plan: `…/Resgrid/Dev/Chatbot/chatbot-phase3-plan.md` → "[v1.2] Implementation Status".
4+
5+
**Key Decisions:**
6+
- Destructive-action confirmation is real: ingress (`ChatbotIngressService` step 5a) re-dispatches a `__confirmed` intent to the owning handler on YES (replaced the engine's faked confirm).
7+
- Outbound = chat as a first-class `CommunicationService` channel: `IChatbotOutboundService` in `Resgrid.Model`, `NullChatbotOutboundService` default (`PreserveExistingDefaults`), real impl + `IChatbotAdapterRegistry` in `Providers.Chatbot`. Same Null+PreserveExistingDefaults pattern for `IChatbotWebChatNotifier`.
8+
- Localization uses culture-explicit `ChatbotResources` (not `IStringLocalizer`), keyed off `ChatbotSession.Culture` (from `UserProfile.Language`); English values kept identical to old literals so tests are unchanged. 21/22 handlers localized in 9 languages (Help left as English command reference).
9+
10+
**Next Steps:**
11+
- Wire `CommunicationService.SendNotificationAsync` + `SendCalendarAsync` to `IChatbotOutboundService` (same one-line pattern as SendMessage/SendCall); do P3.17 (`AddManualIdentity` endpoint, `IChatbotIdentityResolver`, migration `M0071` per-identity outbound prefs).
12+
- Real platform work needing external SDKs/infra: Teams (`Microsoft.Bot.Builder`), Signal (`signald`), Discord/Slack/Telegram rich rendering (real clients), WebChat web-layer SignalR `IChatbotWebChatNotifier` impl + `ChatbotMessageReceived` hub event.
13+
- P3.20 UI (Profile "Linked Chat Accounts" + Admin chatbot config, existing stack); P3.13 Scriban templates; P3.14 multi-turn polish (SetStatus/SetStaffing still prompt-faked); native-speaker review of non-English translations.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Autofac;
2+
using Resgrid.Chatbot.Interfaces;
3+
using Resgrid.Chatbot.NLU.Providers;
4+
using Resgrid.Chatbot.NLU.Services;
5+
6+
namespace Resgrid.Chatbot.NLU
7+
{
8+
public class NLUModule : Module
9+
{
10+
protected override void Load(ContainerBuilder builder)
11+
{
12+
// Keyword classifier (primary, always available, Priority=0)
13+
builder.RegisterType<KeywordIntentClassifier>()
14+
.As<INLUProvider>()
15+
.InstancePerLifetimeScope();
16+
17+
// ML.NET classifier (optional, requires trained model, Priority=10)
18+
builder.RegisterType<MLNetNluProvider>()
19+
.As<INLUProvider>()
20+
.InstancePerLifetimeScope();
21+
22+
// Cloud LLM classifier (OpenAI/DeepSeek/Azure/Anthropic compatible, Priority=100)
23+
builder.RegisterType<OpenAiCompatibleNluProvider>()
24+
.As<INLUProvider>()
25+
.InstancePerLifetimeScope();
26+
27+
// Entity extractor
28+
builder.RegisterType<EntityExtractor>()
29+
.As<IEntityExtractor>()
30+
.InstancePerLifetimeScope();
31+
}
32+
}
33+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.RegularExpressions;
4+
using System.Threading.Tasks;
5+
using Resgrid.Chatbot.Interfaces;
6+
using Resgrid.Chatbot.Models;
7+
8+
namespace Resgrid.Chatbot.NLU.Providers
9+
{
10+
public class KeywordIntentClassifier : INLUProvider
11+
{
12+
public string ProviderName => "Keyword";
13+
public int Priority => 0;
14+
15+
private static readonly List<(Regex pattern, string intent, Func<Match, Dictionary<string, string>> extractParams)> _patterns = new()
16+
{
17+
// === Status Commands (rigid + natural language) ===
18+
(new Regex(@"^(responding|1)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "1")),
19+
(new Regex(@"^(not\s*responding|2)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "2")),
20+
(new Regex(@"^(on\s*scene|onscene|3)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "3")),
21+
(new Regex(@"^(standing\s*by|standingby|4)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "4")),
22+
(new Regex(@"^(i'?m|i\s+am)\s+(responding|on\s*scene|standing\s*by|en\s*route|available|not\s*responding)", RegexOptions.IgnoreCase),
23+
"set_status", m => P("actionType", MapStatusWord(m.Groups[2].Value))),
24+
(new Regex(@"^(set|change|mark)\s+(my\s+)?status\s+to\s+(.+)", RegexOptions.IgnoreCase),
25+
"set_status", m => P("statusName", m.Groups[3].Value.Trim())),
26+
27+
// === Staffing Commands ===
28+
(new Regex(@"^(available|s1)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "1")),
29+
(new Regex(@"^(delayed|s2)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "2")),
30+
(new Regex(@"^(unavailable|s3)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "3")),
31+
(new Regex(@"^(committed|s4)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "4")),
32+
(new Regex(@"^(on\s*shift|onshift|s5)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "5")),
33+
(new Regex(@"^(i'?m|i\s+am)\s+(available|delayed|unavailable|committed|on\s*shift)", RegexOptions.IgnoreCase),
34+
"set_staffing", m => P("staffingType", MapStaffingWord(m.Groups[2].Value))),
35+
(new Regex(@"^(set|change|mark)\s+(my\s+)?staffing\s+to\s+(.+)", RegexOptions.IgnoreCase),
36+
"set_staffing", m => P("staffingName", m.Groups[3].Value.Trim())),
37+
38+
// === Query Commands (rigid) ===
39+
(new Regex(@"^calls?$", RegexOptions.IgnoreCase), "list_calls", null),
40+
(new Regex(@"^c(\d+)$", RegexOptions.IgnoreCase), "call_detail", m => P("callId", m.Groups[1].Value)),
41+
(new Regex(@"^units?$", RegexOptions.IgnoreCase), "list_units", null),
42+
(new Regex(@"^(my\s+)?status$", RegexOptions.IgnoreCase), "my_status", null),
43+
(new Regex(@"^messages?$", RegexOptions.IgnoreCase), "list_messages", null),
44+
(new Regex(@"^(calendar|events?)$", RegexOptions.IgnoreCase), "list_calendar", null),
45+
(new Regex(@"^shifts?$", RegexOptions.IgnoreCase), "list_shifts", null),
46+
(new Regex(@"^(personnel|staff)$", RegexOptions.IgnoreCase), "personnel_lookup", null),
47+
(new Regex(@"^weather$", RegexOptions.IgnoreCase), "weather_alert", null),
48+
49+
// === Help / Stop ===
50+
(new Regex(@"^(help|info|commands|menu|what\s+can\s+you\s+do)$", RegexOptions.IgnoreCase), "help", null),
51+
(new Regex(@"^(stop|end|quit|cancel|unsubscribe)$", RegexOptions.IgnoreCase), "stop", null),
52+
53+
// === Emergency ===
54+
(new Regex(@"^(mayday|emergency|sos|help\s*me|officer\s*down|ff?\s*down|firefighter\s*down)$", RegexOptions.IgnoreCase),
55+
"emergency_mayday", null),
56+
57+
// === Link / Unlink ===
58+
(new Regex(@"^(link|login|verify|auth)$", RegexOptions.IgnoreCase), "link_account", null),
59+
(new Regex(@"^(unlink|logout|unauth)$", RegexOptions.IgnoreCase), "unlink_account", null),
60+
61+
// === Message Detail / Delete / Respond (must precede the natural-language message patterns) ===
62+
(new Regex(@"^#(\d+)$", RegexOptions.IgnoreCase),
63+
"message_detail", m => P("messageId", m.Groups[1].Value)),
64+
(new Regex(@"^(read|show|open|view|get)\s+(message|msg)\s+#?(\d+)", RegexOptions.IgnoreCase),
65+
"message_detail", m => P("messageId", m.Groups[3].Value)),
66+
(new Regex(@"^(delete|remove|del)\s+(message|msg)?\s*#?(\d+)$", RegexOptions.IgnoreCase),
67+
"delete_message", m => P("messageId", m.Groups[3].Value)),
68+
(new Regex(@"^(reply|respond)\s+(yes|no|acknowledge|ack)\s+to\s+(message|msg|#)?\s*#?(\d+)", RegexOptions.IgnoreCase),
69+
"respond_to_message", m => P2("response", m.Groups[2].Value, "messageId", m.Groups[4].Value)),
70+
71+
// === Natural Language Query Commands ===
72+
(new Regex(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)", RegexOptions.IgnoreCase),
73+
"list_calls", null),
74+
(new Regex(@"^(show|tell|get|details?|what\s+about).*\bc(\d+)\b", RegexOptions.IgnoreCase),
75+
"call_detail", m => P("callId", m.Groups[m.Groups.Count - 1].Value)),
76+
(new Regex(@"^(show|list|get|what)\s+(are\s+)?(units?|apparatus|rigs?)", RegexOptions.IgnoreCase),
77+
"list_units", null),
78+
(new Regex(@"^(who|where)\s+(is|are)\s+(.+)", RegexOptions.IgnoreCase),
79+
"personnel_lookup", m => P("query", m.Groups[3].Value.Trim())),
80+
(new Regex(@"^(show|list|get)\s+(personnel|staff|members|crew)", RegexOptions.IgnoreCase),
81+
"personnel_lookup", null),
82+
(new Regex(@"^(what'?s|what\s+is)\s+(my\s+)?(status|staffing)", RegexOptions.IgnoreCase),
83+
"my_status", null),
84+
(new Regex(@"^(check|read|show)\s+(my\s+)?(messages?|inbox)", RegexOptions.IgnoreCase),
85+
"list_messages", null),
86+
(new Regex(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)", RegexOptions.IgnoreCase),
87+
"list_calendar", null),
88+
(new Regex(@"^(show|list|get|my)\s+shifts?", RegexOptions.IgnoreCase),
89+
"list_shifts", null),
90+
(new Regex(@"^(weather\s+)?(alerts?|warnings?)", RegexOptions.IgnoreCase),
91+
"weather_alert", null),
92+
93+
// === Send Message ===
94+
(new Regex(@"^send\s+message\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase),
95+
"send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())),
96+
(new Regex(@"^(msg|message)\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase),
97+
"send_message", m => P2("recipient", m.Groups[2].Value.Trim(), "body", m.Groups[3].Value.Trim())),
98+
(new Regex(@"^tell\s+(.+?)\s+(.+)", RegexOptions.IgnoreCase),
99+
"send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())),
100+
101+
// === Dispatch ===
102+
(new Regex(@"^(dispatch|create\s+call|new\s+call)\s+(.+)", RegexOptions.IgnoreCase),
103+
"dispatch_call", m => P("description", m.Groups[2].Value.Trim())),
104+
(new Regex(@"^report\s+(.+)", RegexOptions.IgnoreCase),
105+
"dispatch_call", m => P("description", m.Groups[1].Value.Trim())),
106+
107+
// === Close Call ===
108+
(new Regex(@"^(close|end|cancel)\s+call\s+c?(\d+)", RegexOptions.IgnoreCase),
109+
"close_call", m => P("callId", m.Groups[2].Value)),
110+
(new Regex(@"^(close|end|cancel)\s+c(\d+)", RegexOptions.IgnoreCase),
111+
"close_call", m => P("callId", m.Groups[2].Value)),
112+
113+
// === Respond to Call ===
114+
(new Regex(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)", RegexOptions.IgnoreCase),
115+
"respond_to_call", m => P("callId", m.Groups[2].Value)),
116+
117+
// === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) ===
118+
(new Regex(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)", RegexOptions.IgnoreCase),
119+
"shift_drop", m => P("shiftId", m.Groups[3].Value)),
120+
121+
// === Shift Signup ===
122+
(new Regex(@"^(sign\s*up|take)\s+shift\s+(.+)", RegexOptions.IgnoreCase),
123+
"shift_signup", m => P("shiftId", m.Groups[2].Value.Trim())),
124+
125+
// === RSVP Calendar ===
126+
(new Regex(@"^rsvp\s+(yes|no|maybe)\s+to\s+(.+)", RegexOptions.IgnoreCase),
127+
"rsvp_calendar", m => P2("response", m.Groups[1].Value, "eventId", m.Groups[2].Value.Trim())),
128+
129+
// === Calendar / Shift Detail (query suffix) ===
130+
(new Regex(@"^(calendar|events?)\s+(.+)$", RegexOptions.IgnoreCase),
131+
"calendar_detail", m => P("query", m.Groups[2].Value.Trim())),
132+
(new Regex(@"^shifts?\s+(.+)$", RegexOptions.IgnoreCase),
133+
"shift_detail", m => P("query", m.Groups[1].Value.Trim())),
134+
135+
// === Set Unit Status ===
136+
(new Regex(@"^set\s+unit\s+(.+?)\s+to\s+(.+)", RegexOptions.IgnoreCase),
137+
"set_unit_status", m => P2("unitName", m.Groups[1].Value.Trim(), "status", m.Groups[2].Value.Trim())),
138+
139+
// === Department Management ===
140+
(new Regex(@"^(departments|depts|my\s+departments|my\s+depts|which\s+departments)$", RegexOptions.IgnoreCase),
141+
"list_departments", null),
142+
(new Regex(@"^(show|list|get|what|what'?s)\s+(my\s+)?(departments?|depts?)$", RegexOptions.IgnoreCase),
143+
"list_departments", null),
144+
(new Regex(@"^(active\s+department|current\s+department|which\s+department|what\s+department)\s*(am\s+i\s+in)?\??$", RegexOptions.IgnoreCase),
145+
"get_active_department", null),
146+
(new Regex(@"^(switch|change|set)\s+(to\s+)?(department|dept)\s+(.+)$", RegexOptions.IgnoreCase),
147+
"switch_department", m => P("departmentIdentifier", m.Groups[4].Value.Trim())),
148+
(new Regex(@"^(switch|change|set)\s+(my\s+)?(active\s+)?(department|dept)\s*$", RegexOptions.IgnoreCase),
149+
"list_departments", null),
150+
};
151+
152+
public Task<NLUResult> ClassifyAsync(string text, string context = null, int departmentId = 0)
153+
{
154+
if (string.IsNullOrWhiteSpace(text))
155+
return Task.FromResult(new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName });
156+
157+
var trimmed = text.Trim();
158+
159+
// Check all patterns in priority order
160+
foreach (var (pattern, intent, extractor) in _patterns)
161+
{
162+
var match = pattern.Match(trimmed);
163+
if (match.Success)
164+
{
165+
return Task.FromResult(new NLUResult
166+
{
167+
IntentName = intent,
168+
Parameters = extractor?.Invoke(match) ?? new Dictionary<string, string>(),
169+
Confidence = 1.0,
170+
ProviderName = ProviderName
171+
});
172+
}
173+
}
174+
175+
// Fuzzy fallback: check partial keyword matches for common intents
176+
var lower = trimmed.ToLowerInvariant();
177+
if (lower.Contains("call") && (lower.Contains("active") || lower.Contains("open") || lower.Contains("list")))
178+
return Task.FromResult(new NLUResult { IntentName = "list_calls", Parameters = new Dictionary<string, string>(), Confidence = 0.7, ProviderName = ProviderName });
179+
180+
if (lower.Contains("message") && (lower.Contains("send") || lower.Contains("tell")))
181+
return Task.FromResult(new NLUResult { IntentName = "send_message", Parameters = new Dictionary<string, string> { ["body"] = trimmed }, Confidence = 0.6, ProviderName = ProviderName });
182+
183+
if (lower.Contains("status") && (lower.Contains("my") || lower.Contains("what")))
184+
return Task.FromResult(new NLUResult { IntentName = "my_status", Confidence = 0.6, ProviderName = ProviderName });
185+
186+
if (lower.Contains("shift"))
187+
return Task.FromResult(new NLUResult { IntentName = "list_shifts", Confidence = 0.5, ProviderName = ProviderName });
188+
189+
if (lower.Contains("who") || lower.Contains("where"))
190+
return Task.FromResult(new NLUResult { IntentName = "personnel_lookup", Parameters = new Dictionary<string, string> { ["query"] = trimmed }, Confidence = 0.5, ProviderName = ProviderName });
191+
192+
return Task.FromResult(new NLUResult
193+
{
194+
IntentName = "unknown",
195+
Confidence = 0,
196+
ProviderName = ProviderName
197+
});
198+
}
199+
200+
public Task<bool> IsAvailableAsync()
201+
{
202+
return Task.FromResult(true);
203+
}
204+
205+
private static Dictionary<string, string> P(string key, string value)
206+
{
207+
return new Dictionary<string, string> { [key] = value };
208+
}
209+
210+
private static Dictionary<string, string> P2(string k1, string v1, string k2, string v2)
211+
{
212+
return new Dictionary<string, string> { [k1] = v1, [k2] = v2 };
213+
}
214+
215+
private static string MapStatusWord(string word)
216+
{
217+
var w = word.ToLowerInvariant().Replace(" ", "");
218+
return w switch
219+
{
220+
"responding" => "1",
221+
"notresponding" => "2",
222+
"onscene" => "3",
223+
"standingby" => "4",
224+
"enroute" => "1",
225+
"available" => "4",
226+
_ => "1"
227+
};
228+
}
229+
230+
private static string MapStaffingWord(string word)
231+
{
232+
var w = word.ToLowerInvariant().Replace(" ", "");
233+
return w switch
234+
{
235+
"available" => "1",
236+
"delayed" => "2",
237+
"unavailable" => "3",
238+
"committed" => "4",
239+
"onshift" => "5",
240+
_ => "1"
241+
};
242+
}
243+
}
244+
}

0 commit comments

Comments
 (0)