Skip to content

Commit d54a7b3

Browse files
committed
RE1-T117 First pass framework for chatbot
1 parent ffb5e50 commit d54a7b3

73 files changed

Lines changed: 6171 additions & 1164 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.
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: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
// === Natural Language Query Commands ===
62+
(new Regex(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)", RegexOptions.IgnoreCase),
63+
"list_calls", null),
64+
(new Regex(@"^(show|tell|get|details?|what\s+about).*\bc(\d+)\b", RegexOptions.IgnoreCase),
65+
"call_detail", m => P("callId", m.Groups[m.Groups.Count - 1].Value)),
66+
(new Regex(@"^(show|list|get|what)\s+(are\s+)?(units?|apparatus|rigs?)", RegexOptions.IgnoreCase),
67+
"list_units", null),
68+
(new Regex(@"^(who|where)\s+(is|are)\s+(.+)", RegexOptions.IgnoreCase),
69+
"personnel_lookup", m => P("query", m.Groups[3].Value.Trim())),
70+
(new Regex(@"^(show|list|get)\s+(personnel|staff|members|crew)", RegexOptions.IgnoreCase),
71+
"personnel_lookup", null),
72+
(new Regex(@"^(what'?s|what\s+is)\s+(my\s+)?(status|staffing)", RegexOptions.IgnoreCase),
73+
"my_status", null),
74+
(new Regex(@"^(check|read|show)\s+(my\s+)?(messages?|inbox)", RegexOptions.IgnoreCase),
75+
"list_messages", null),
76+
(new Regex(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)", RegexOptions.IgnoreCase),
77+
"list_calendar", null),
78+
(new Regex(@"^(show|list|get|my)\s+shifts?", RegexOptions.IgnoreCase),
79+
"list_shifts", null),
80+
(new Regex(@"^(weather\s+)?(alerts?|warnings?)", RegexOptions.IgnoreCase),
81+
"weather_alert", null),
82+
83+
// === Send Message ===
84+
(new Regex(@"^send\s+message\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase),
85+
"send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())),
86+
(new Regex(@"^(msg|message)\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase),
87+
"send_message", m => P2("recipient", m.Groups[2].Value.Trim(), "body", m.Groups[3].Value.Trim())),
88+
(new Regex(@"^tell\s+(.+?)\s+(.+)", RegexOptions.IgnoreCase),
89+
"send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())),
90+
91+
// === Dispatch ===
92+
(new Regex(@"^(dispatch|create\s+call|new\s+call)\s+(.+)", RegexOptions.IgnoreCase),
93+
"dispatch_call", m => P("description", m.Groups[2].Value.Trim())),
94+
(new Regex(@"^report\s+(.+)", RegexOptions.IgnoreCase),
95+
"dispatch_call", m => P("description", m.Groups[1].Value.Trim())),
96+
97+
// === Close Call ===
98+
(new Regex(@"^(close|end|cancel)\s+call\s+c?(\d+)", RegexOptions.IgnoreCase),
99+
"close_call", m => P("callId", m.Groups[2].Value)),
100+
(new Regex(@"^(close|end|cancel)\s+c(\d+)", RegexOptions.IgnoreCase),
101+
"close_call", m => P("callId", m.Groups[2].Value)),
102+
103+
// === Respond to Call ===
104+
(new Regex(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)", RegexOptions.IgnoreCase),
105+
"respond_to_call", m => P("callId", m.Groups[2].Value)),
106+
107+
// === Shift Signup ===
108+
(new Regex(@"^(sign\s*up|take)\s+shift\s+(.+)", RegexOptions.IgnoreCase),
109+
"shift_signup", m => P("shiftId", m.Groups[2].Value.Trim())),
110+
111+
// === RSVP Calendar ===
112+
(new Regex(@"^rsvp\s+(yes|no|maybe)\s+to\s+(.+)", RegexOptions.IgnoreCase),
113+
"rsvp_calendar", m => P2("response", m.Groups[1].Value, "eventId", m.Groups[2].Value.Trim())),
114+
115+
// === Calendar / Shift Detail (query suffix) ===
116+
(new Regex(@"^(calendar|events?)\s+(.+)$", RegexOptions.IgnoreCase),
117+
"calendar_detail", m => P("query", m.Groups[2].Value.Trim())),
118+
(new Regex(@"^shifts?\s+(.+)$", RegexOptions.IgnoreCase),
119+
"shift_detail", m => P("query", m.Groups[1].Value.Trim())),
120+
121+
// === Set Unit Status ===
122+
(new Regex(@"^set\s+unit\s+(.+?)\s+to\s+(.+)", RegexOptions.IgnoreCase),
123+
"set_unit_status", m => P2("unitName", m.Groups[1].Value.Trim(), "status", m.Groups[2].Value.Trim())),
124+
125+
// === Department Management ===
126+
(new Regex(@"^(departments|depts|my\s+departments|my\s+depts|which\s+departments)$", RegexOptions.IgnoreCase),
127+
"list_departments", null),
128+
(new Regex(@"^(show|list|get|what|what'?s)\s+(my\s+)?(departments?|depts?)$", RegexOptions.IgnoreCase),
129+
"list_departments", null),
130+
(new Regex(@"^(active\s+department|current\s+department|which\s+department|what\s+department)\s*(am\s+i\s+in)?\??$", RegexOptions.IgnoreCase),
131+
"get_active_department", null),
132+
(new Regex(@"^(switch|change|set)\s+(to\s+)?(department|dept)\s+(.+)$", RegexOptions.IgnoreCase),
133+
"switch_department", m => P("departmentIdentifier", m.Groups[4].Value.Trim())),
134+
(new Regex(@"^(switch|change|set)\s+(my\s+)?(active\s+)?(department|dept)\s*$", RegexOptions.IgnoreCase),
135+
"list_departments", null),
136+
};
137+
138+
public Task<NLUResult> ClassifyAsync(string text, string context = null)
139+
{
140+
if (string.IsNullOrWhiteSpace(text))
141+
return Task.FromResult(new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName });
142+
143+
var trimmed = text.Trim();
144+
145+
// Check all patterns in priority order
146+
foreach (var (pattern, intent, extractor) in _patterns)
147+
{
148+
var match = pattern.Match(trimmed);
149+
if (match.Success)
150+
{
151+
return Task.FromResult(new NLUResult
152+
{
153+
IntentName = intent,
154+
Parameters = extractor?.Invoke(match) ?? new Dictionary<string, string>(),
155+
Confidence = 1.0,
156+
ProviderName = ProviderName
157+
});
158+
}
159+
}
160+
161+
// Fuzzy fallback: check partial keyword matches for common intents
162+
var lower = trimmed.ToLowerInvariant();
163+
if (lower.Contains("call") && (lower.Contains("active") || lower.Contains("open") || lower.Contains("list")))
164+
return Task.FromResult(new NLUResult { IntentName = "list_calls", Parameters = new Dictionary<string, string>(), Confidence = 0.7, ProviderName = ProviderName });
165+
166+
if (lower.Contains("message") && (lower.Contains("send") || lower.Contains("tell")))
167+
return Task.FromResult(new NLUResult { IntentName = "send_message", Parameters = new Dictionary<string, string> { ["body"] = trimmed }, Confidence = 0.6, ProviderName = ProviderName });
168+
169+
if (lower.Contains("status") && (lower.Contains("my") || lower.Contains("what")))
170+
return Task.FromResult(new NLUResult { IntentName = "my_status", Confidence = 0.6, ProviderName = ProviderName });
171+
172+
if (lower.Contains("shift"))
173+
return Task.FromResult(new NLUResult { IntentName = "list_shifts", Confidence = 0.5, ProviderName = ProviderName });
174+
175+
if (lower.Contains("who") || lower.Contains("where"))
176+
return Task.FromResult(new NLUResult { IntentName = "personnel_lookup", Parameters = new Dictionary<string, string> { ["query"] = trimmed }, Confidence = 0.5, ProviderName = ProviderName });
177+
178+
return Task.FromResult(new NLUResult
179+
{
180+
IntentName = "unknown",
181+
Confidence = 0,
182+
ProviderName = ProviderName
183+
});
184+
}
185+
186+
public Task<bool> IsAvailableAsync()
187+
{
188+
return Task.FromResult(true);
189+
}
190+
191+
private static Dictionary<string, string> P(string key, string value)
192+
{
193+
return new Dictionary<string, string> { [key] = value };
194+
}
195+
196+
private static Dictionary<string, string> P2(string k1, string v1, string k2, string v2)
197+
{
198+
return new Dictionary<string, string> { [k1] = v1, [k2] = v2 };
199+
}
200+
201+
private static string MapStatusWord(string word)
202+
{
203+
var w = word.ToLowerInvariant().Replace(" ", "");
204+
return w switch
205+
{
206+
"responding" => "1",
207+
"notresponding" => "2",
208+
"onscene" => "3",
209+
"standingby" => "4",
210+
"enroute" => "1",
211+
"available" => "4",
212+
_ => "1"
213+
};
214+
}
215+
216+
private static string MapStaffingWord(string word)
217+
{
218+
var w = word.ToLowerInvariant().Replace(" ", "");
219+
return w switch
220+
{
221+
"available" => "1",
222+
"delayed" => "2",
223+
"unavailable" => "3",
224+
"committed" => "4",
225+
"onshift" => "5",
226+
_ => "1"
227+
};
228+
}
229+
}
230+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Resgrid.Chatbot.Interfaces;
4+
using Resgrid.Chatbot.Models;
5+
6+
namespace Resgrid.Chatbot.NLU.Providers
7+
{
8+
public class MLNetNluProvider : INLUProvider
9+
{
10+
public string ProviderName => "MLNet";
11+
public int Priority => 10;
12+
13+
public MLNetNluProvider()
14+
{
15+
}
16+
17+
public Task<NLUResult> ClassifyAsync(string text, string context = null)
18+
{
19+
if (string.IsNullOrWhiteSpace(text))
20+
return Task.FromResult(new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName });
21+
22+
// Phase 2: ML.NET placeholder.
23+
// When a trained model is available at ChatbotConfig.MlNetModelPath,
24+
// this will load the model and use PredictionEngine to classify.
25+
// For now, falls back to returning unknown so the router uses keyword classifier.
26+
27+
return Task.FromResult(new NLUResult
28+
{
29+
IntentName = "unknown",
30+
Confidence = 0,
31+
ProviderName = ProviderName,
32+
RawResponse = "ML.NET model not yet trained. Use Keyword provider."
33+
});
34+
}
35+
36+
public Task<bool> IsAvailableAsync()
37+
{
38+
// Check if model file exists
39+
var modelPath = Resgrid.Chatbot.Config.ChatbotConfig.MlNetModelPath;
40+
if (string.IsNullOrWhiteSpace(modelPath))
41+
return Task.FromResult(false);
42+
43+
return Task.FromResult(System.IO.File.Exists(modelPath));
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)