Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,5 @@ Web/Resgrid.WebCore/wwwroot/lib/*
opencode.json
.dual-graph-pro/
.mcp.json
/.codex
/.claude
102 changes: 90 additions & 12 deletions Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,28 @@ public class KeywordIntentClassifier : INLUProvider
// === Status Commands (rigid + natural language) ===
// The SMS shortcut numbers (1-4) are the user-facing scheme from the legacy text commands;
// the emitted actionType values are ActionTypes enum values (Responding=2, NotResponding=1,
// OnScene=3, StandingBy/Available=0) — the handler passes them straight to SetUserActionAsync.
(R(@"^(responding|1)$"), "set_status", m => P("actionType", "2")),
(R(@"^(not\s*responding|2)$"), "set_status", m => P("actionType", "1")),
// OnScene=3, StandingBy/Available=0). The handler maps them through department custom states.
// Bare response phrases acknowledge the user's most recent call dispatch. Explicit status
// commands ("set my status to ...") continue through the general status handler.
(R(@"^(responding|omw|on\s+my\s+way|enroute|en\s+route)$"),
"respond_to_call", m => P("response", "yes")),
(R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)$"),
"respond_to_call", m => P("response", "no")),
(R(@"^1$"), "set_status", m => P("actionType", "2")),
(R(@"^2$"), "set_status", m => P("actionType", "1")),
(R(@"^(on\s*scene|onscene|3)$"), "set_status", m => P("actionType", "3")),
(R(@"^(standing\s*by|standingby|4)$"), "set_status", m => P("actionType", "0")),
// Responder shorthand for "responding" with no target call.
(R(@"^(omw|on\s+my\s+way|enroute|en\s+route)$"),
"set_status", m => P("actionType", "2")),
(R(@"^(i'?m|i\s+am)\s+(responding|on\s*scene|standing\s*by|en\s*route|available|not\s*responding)"),
(R(@"^(i'?m|i\s+am)\s+(responding|on\s+my\s+way|en\s*route|not\s*responding|not\s+going)$"),
"respond_to_call", m => P("response", IsNegativeCallResponse(m.Groups[2].Value) ? "no" : "yes")),
(R(@"^(i'?m|i\s+am)\s+(on\s*scene|standing\s*by|available)"),
"set_status", m => P("actionType", MapStatusWord(m.Groups[2].Value))),
(R(@"^(set|change|mark)\s+(my\s+)?status\s+to\s+(.+)"),
"set_status", m => P("statusName", m.Groups[3].Value.Trim())),

// === Staffing Commands ===
// S1-S5 is the user-facing scheme; emitted staffingType values are UserStateTypes enum values
// (Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4) — passed straight to CreateUserState.
// (Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4). The handler maps them
// through the department's configured staffing levels.
(R(@"^(available|s1)$"), "set_staffing", m => P("staffingType", "0")),
(R(@"^(delayed|s2)$"), "set_staffing", m => P("staffingType", "1")),
(R(@"^(unavailable|s3)$"), "set_staffing", m => P("staffingType", "2")),
Expand All @@ -53,6 +59,8 @@ public class KeywordIntentClassifier : INLUProvider
// "my staffing" — the my_status handler reports both status and staffing.
(R(@"^(my\s+)?staffing$"), "my_status", null),
(R(@"^(messages?|msgs?)$"), "list_messages", null),
// Unread/new message forms route to the same list handler (it lists unread only).
(R(@"^(any\s+|my\s+)?(unread|new)\s+(messages?|msgs?)$"), "list_messages", null),
(R(@"^(calendar|events?|cal)$"), "list_calendar", null),
(R(@"^shifts?$"), "list_shifts", null),
(R(@"^(personnel|staff)$"), "personnel_lookup", null),
Expand Down Expand Up @@ -87,6 +95,63 @@ public class KeywordIntentClassifier : INLUProvider
(R(@"^(reply|respond)\s+(yes|no|acknowledge|ack)\s+to\s+(message|msg|#)?\s*#?(\d+)"),
"respond_to_message", m => P2("response", m.Groups[2].Value, "messageId", m.Groups[4].Value)),

// === Availability / Call Responder Queries (must precede the generic
// "who is X" personnel_lookup and "what ... calls" list_calls patterns) ===

// "who's available?", "who is around?", "anyone free?", "who can respond?"
(R(@"^(who'?s|who\s+is|who\s+are|anyone|anybody|any\s*one)\s+(around|available|free)(\s+to\s+respond)?$"),
"who_available", null),
(R(@"^who\s+can\s+respond$"),
"who_available", null),

// "units available?", "available units", "what units are available/free/in service"
(R(@"^(available|free)\s+units?$"),
"units_available", null),
(R(@"^units?\s+(are\s+)?(available|free|in\s+service)$"),
"units_available", null),
(R(@"^(what|which)\s+units?\s+(are\s+)?(available|free|in\s+service)$"),
"units_available", null),

// "who's on scene at the fire" — on-scene responders for a call.
(R(@"^(who'?s|who\s+is|who\s+are)\s+on\s*scene(\s+(?:at|on|for)\s+(.+))?$"),
"call_responders", m => P2("mode", "onscene", "callRef", CleanReference(m.Groups[3].Value))),

// "who's in route to the fire", "who is responding to c1445", "who's coming"
(R(@"^(who'?s|who\s+is|who\s+are)\s+((?:in|en)\s*route|responding|headed|heading|going|coming)(\s+(?:to|for)\s+(.+))?$"),
"call_responders", m => P2("mode", "enroute", "callRef", CleanReference(m.Groups[4].Value))),

// "who got dispatched to the medical", "who's dispatched to 26-1" — the full dispatch
// list (personnel, groups, roles and units) rather than live statuses.
(R(@"^(who'?s|who\s+is|who\s+are|who\s+got|who\s+was|who\s+were|who)\s+dispatched(\s+(?:to|on|for)\s+(.+))?$"),
"call_dispatched", m => P("callRef", CleanReference(m.Groups[3].Value))),

// "who's on call 26-1", "who is on the fire" — responding + on-scene for a call.
(R(@"^(who'?s|who\s+is|who\s+are)\s+on(\s+call)?(\s+(.+))?$"),
"call_responders", m => P2("mode", "all", "callRef", CleanReference(m.Groups[4].Value))),

// "what calls am I on?", "my calls" — calls the user was dispatched to.
(R(@"^(what\s+)?calls?\s+am\s+i\s+(on|dispatched\s+to|assigned\s+to)\b.*$"),
"my_calls", null),
(R(@"^my\s+calls?$"),
"my_calls", null),
(R(@"^what\s+am\s+i\s+dispatched\s+to$"),
"my_calls", null),

// "what calls is Rescue 6 on?" — calls a unit was dispatched to.
(R(@"^(what\s+)?calls?\s+(is|are)\s+(.+?)\s+(on|dispatched\s+to|assigned\s+to)$"),
"unit_calls", m => P("unitName", m.Groups[3].Value.Trim())),
(R(@"^what\s+is\s+(.+?)\s+dispatched\s+to$"),
"unit_calls", m => P("unitName", m.Groups[1].Value.Trim())),

// "what's my schedule?", "my schedule for 7/22" — shifts + RSVP'd events.
(R(@"^(what'?s\s+|what\s+is\s+)?my\s+schedule(\s+(?:for\s+|on\s+)?(.+))?$"),
"my_schedule", m => P("day", m.Groups[3].Value.Trim())),

// "poll members to see who's available for a red flag on 7/22" — the handler strips
// leading audience/verb filler from the question text.
(R(@"^(send\s+a\s+poll|send\s+poll|poll)\s+(.+)$"),
"create_poll", m => P("question", m.Groups[2].Value.Trim())),

// === Natural Language Query Commands ===
(R(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)"),
"list_calls", null),
Expand Down Expand Up @@ -134,13 +199,15 @@ public class KeywordIntentClassifier : INLUProvider
"close_call", m => P("callId", m.Groups[2].Value)),

// === Respond to Call ===
(R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)\s+(?:to\s+)?(.+)$"),
"respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "no")),
(R(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)$"),
"respond_to_call", m => P("callId", m.Groups[2].Value)),
"respond_to_call", m => P2("callId", m.Groups[2].Value, "response", "yes")),
// Responder shorthand: "omw to 26-1", "omw to fire", "enroute to c1445", "headed to Main St".
// The reference can be a call id, a call number (yy-N), or a term matched against active
// calls — resolved by the handler.
(R(@"^(omw|on\s+my\s+way|respond(?:ing)?|going|headed|enroute|en\s+route)\s+(?:to\s+)?(.+)$"),
"respond_to_call", m => P("callRef", m.Groups[2].Value.Trim())),
"respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "yes")),

// === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) ===
(R(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)"),
Expand Down Expand Up @@ -202,9 +269,9 @@ public Task<NLUResult> ClassifyAsync(string text, string context = null, int dep
: new[] { trimmed };

// Check all patterns in priority order
foreach (var candidate in candidates)
foreach (var (pattern, intent, extractor) in _patterns)
{
foreach (var (pattern, intent, extractor) in _patterns)
foreach (var candidate in candidates)
{
var match = pattern.Match(candidate);
if (match.Success)
Expand Down Expand Up @@ -270,6 +337,17 @@ private static Dictionary<string, string> P2(string k1, string v1, string k2, st
return new Dictionary<string, string> { [k1] = v1, [k2] = v2 };
}

private static string CleanReference(string value)
{
return value?.Trim().TrimEnd('?', '!', '.', ',');
}

private static bool IsNegativeCallResponse(string value)
{
var normalized = value?.ToLowerInvariant().Replace(" ", string.Empty);
return normalized == "notresponding" || normalized == "notgoing";
}

// ActionTypes enum values: Responding=2, NotResponding=1, OnScene=3, StandingBy/Available=0.
private static string MapStatusWord(string word)
{
Expand Down
18 changes: 16 additions & 2 deletions Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class OpenAiCompatibleNluProvider : INLUProvider
- my_status: User asks about their current status or staffing level
- list_calls: User wants to see active/open calls/incidents
- call_detail: User asks about a specific call by number/name
- respond_to_call: User says they are responding/going/en route to a specific call
- respond_to_call: User acknowledges or declines a call response. Bare phrases such as ""responding"", ""on my way"", ""omw"", ""not responding"", or ""not going"" apply to the user's most recent dispatch; a call reference is optional
- close_call: User wants to close/cancel/end a call
- dispatch_call: User wants to create/draft a new call or incident (dispatch role)
- list_units: User wants to see unit statuses
Expand All @@ -70,6 +70,14 @@ public class OpenAiCompatibleNluProvider : INLUProvider
- shift_signup: User wants to sign up for a shift
- shift_drop: User wants to drop a shift
- personnel_lookup: User asks about personnel (who is X, where is X, list personnel)
- who_available: User asks who is available/around/free to respond right now (e.g. ""who's around?"", ""who is available?"")
- units_available: User asks which units are available/free/in service right now
- call_responders: User asks who is on a call, responding, en route or on scene for a call (e.g. ""who's on call 26-1?"", ""who's in route to the fire?"")
- call_dispatched: User asks who was dispatched/assigned to a call (the dispatch list: personnel, groups, roles, units)
- my_calls: User asks what calls THEY are on/dispatched to (e.g. ""what calls am I on?"")
- unit_calls: User asks what calls a specific unit is on/dispatched to (e.g. ""what calls is Rescue 6 on?"")
- create_poll: User wants to poll/survey department members with a yes/no question (e.g. ""poll members to see who's available for a red flag on 7/22"")
- my_schedule: User asks for their schedule (their shifts plus calendar events they RSVP'd to), optionally for a specific day
- weather_alert: User asks about weather alerts or warnings
- emergency_mayday: User signals emergency/distress (mayday, officer down, firefighter down)
- link_account: User wants to link/authenticate their account
Expand All @@ -88,7 +96,8 @@ public class OpenAiCompatibleNluProvider : INLUProvider
Extract any parameters mentioned:
- For set_status: ""statusName"" (the status name), or ""actionType"" (numeric ID 1-4)
- For set_staffing: ""staffingName"" (the staffing name), or ""staffingType"" (numeric S1-S5)
- For call_detail/close_call/respond_to_call: ""callId"" (the call number)
- For call_detail/close_call: ""callId"" (the call number)
- For respond_to_call: ""callId"" or ""callRef"" when provided, and ""response"" (""yes"" for responding/going, ""no"" for not responding/not going)
- For set_unit_status: ""unitName"", ""status""
- For send_message: ""recipient"", ""body""
- For respond_to_message: ""messageId"", ""response"" (yes/no)
Expand All @@ -98,6 +107,11 @@ public class OpenAiCompatibleNluProvider : INLUProvider
- For personnel_lookup: ""query"" (the name/description)
- For dispatch_call: ""description"" (the call description)
- For switch_department: ""departmentIdentifier"" (the department name, code, or number to switch to)
- For call_responders: ""callRef"" (call id, number or shorthand like ""the fire""; empty if not given), ""mode"" (""all"", ""enroute"" or ""onscene"" — enroute when the user asks who is responding/in route, onscene when they ask who is on scene, all otherwise)
- For call_dispatched: ""callRef"" (call id, number or shorthand; empty if not given)
- For unit_calls: ""unitName"" (the unit name, number or id)
- For create_poll: ""question"" (the yes/no question to ask members, without the leading ""poll members to see"" filler)
- For my_schedule: ""day"" (the day asked about, e.g. ""today"", ""tomorrow"", ""7/22""; empty for today)
- For list_departments: no parameters needed
- For get_active_department: no parameters needed

Expand Down
32 changes: 32 additions & 0 deletions Core/Resgrid.Chatbot/ChatbotModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ protected override void Load(ContainerBuilder builder)
.As<IChatbotUserSearchService>()
.InstancePerLifetimeScope();

builder.RegisterType<TextResponseResolver>()
.As<ITextResponseResolver>()
.InstancePerLifetimeScope();

// Default no-op Web Chat notifier; the real SignalR-backed notifier in the web layer
// overrides this (PreserveExistingDefaults keeps the real one winning regardless of order).
builder.RegisterType<NullChatbotWebChatNotifier>()
Expand Down Expand Up @@ -165,6 +169,34 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType<WeatherAlertHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<AvailabilityActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<UnitsAvailableActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<CallRespondersActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<CallDispatchedActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<MyCallsActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<PollCreateHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();

builder.RegisterType<MyScheduleActionHandler>()
.As<IChatbotActionHandler>()
.InstancePerLifetimeScope();
}
}
}
Loading
Loading