Skip to content

Commit 9466af3

Browse files
committed
RE1-T117 PR#391 fixes
1 parent e8daf03 commit 9466af3

64 files changed

Lines changed: 4457 additions & 280 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
}

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.

Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ public class KeywordIntentClassifier : INLUProvider
5858
(new Regex(@"^(link|login|verify|auth)$", RegexOptions.IgnoreCase), "link_account", null),
5959
(new Regex(@"^(unlink|logout|unauth)$", RegexOptions.IgnoreCase), "unlink_account", null),
6060

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+
6171
// === Natural Language Query Commands ===
6272
(new Regex(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)", RegexOptions.IgnoreCase),
6373
"list_calls", null),
@@ -104,6 +114,10 @@ public class KeywordIntentClassifier : INLUProvider
104114
(new Regex(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)", RegexOptions.IgnoreCase),
105115
"respond_to_call", m => P("callId", m.Groups[2].Value)),
106116

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+
107121
// === Shift Signup ===
108122
(new Regex(@"^(sign\s*up|take)\s+shift\s+(.+)", RegexOptions.IgnoreCase),
109123
"shift_signup", m => P("shiftId", m.Groups[2].Value.Trim())),

Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs

Lines changed: 119 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
using System.Diagnostics;
44
using System.Net.Http;
55
using System.Text;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Newtonsoft.Json;
89
using Newtonsoft.Json.Linq;
10+
using Resgrid.Framework;
911
using Resgrid.Chatbot.Config;
1012
using Resgrid.Chatbot.Interfaces;
1113
using Resgrid.Chatbot.Models;
@@ -36,7 +38,11 @@ public class OpenAiCompatibleNluProvider : INLUProvider
3638
public string ProviderName => "CloudLLM";
3739
public int Priority => 100;
3840

39-
private readonly HttpClient _httpClient;
41+
// A single shared HttpClient avoids socket exhaustion. This provider is registered
42+
// InstancePerLifetimeScope, so a per-instance client would leak sockets under load.
43+
// Per-request timeouts are enforced via a CancellationToken (see ClassifyAsync) rather
44+
// than the shared client's Timeout, which cannot be varied safely across concurrent callers.
45+
private static readonly HttpClient _httpClient = new HttpClient();
4046
private readonly IChatbotDepartmentConfigService _configService;
4147

4248
private static readonly string IntentSystemPrompt = @"You are a classification engine for emergency service chatbot commands.
@@ -101,12 +107,6 @@ Confidence should be between 0.0 and 1.0 based on how certain you are.
101107
public OpenAiCompatibleNluProvider(IChatbotDepartmentConfigService configService)
102108
{
103109
_configService = configService;
104-
_httpClient = new HttpClient
105-
{
106-
Timeout = TimeSpan.FromSeconds(ChatbotConfig.CloudNluTimeoutSeconds > 0
107-
? ChatbotConfig.CloudNluTimeoutSeconds
108-
: 10)
109-
};
110110
}
111111

112112
public async Task<NLUResult> ClassifyAsync(string text, string context = null, int departmentId = 0)
@@ -142,30 +142,60 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
142142
};
143143
}
144144

145+
// Anthropic uses a different request/response schema and auth header than the OpenAI
146+
// chat-completions API. Department overrides carry no provider type, so detect Anthropic
147+
// from the endpoint URL; otherwise honour the system-level provider setting.
148+
var isAnthropic = departmentLlm != null
149+
? (!string.IsNullOrWhiteSpace(endpoint) && endpoint.IndexOf("anthropic", StringComparison.OrdinalIgnoreCase) >= 0)
150+
: ChatbotConfig.CloudNluProvider == CloudNluProviderType.Anthropic;
151+
145152
var systemPrompt = !string.IsNullOrWhiteSpace(ChatbotConfig.CloudNluSystemPrompt)
146153
? ChatbotConfig.CloudNluSystemPrompt
147154
: IntentSystemPrompt;
148155

149-
var messages = new List<object>
150-
{
151-
new { role = "system", content = systemPrompt }
152-
};
156+
var maxTokens = ChatbotConfig.CloudNluMaxTokens > 0 ? ChatbotConfig.CloudNluMaxTokens : 256;
153157

154-
if (!string.IsNullOrWhiteSpace(context))
158+
object requestBody;
159+
if (isAnthropic)
155160
{
156-
messages.Add(new { role = "system", content = $"Conversation context: {context}" });
161+
// Anthropic /v1/messages: the system prompt is a top-level field, messages contain
162+
// only user/assistant turns, and there is no response_format option.
163+
var anthropicSystem = string.IsNullOrWhiteSpace(context)
164+
? systemPrompt
165+
: $"{systemPrompt}\n\nConversation context: {context}";
166+
167+
requestBody = new
168+
{
169+
model,
170+
max_tokens = maxTokens,
171+
temperature = ChatbotConfig.CloudNluTemperature,
172+
system = anthropicSystem,
173+
messages = new[] { new { role = "user", content = text } }
174+
};
157175
}
176+
else
177+
{
178+
var messages = new List<object>
179+
{
180+
new { role = "system", content = systemPrompt }
181+
};
158182

159-
messages.Add(new { role = "user", content = text });
183+
if (!string.IsNullOrWhiteSpace(context))
184+
{
185+
messages.Add(new { role = "system", content = $"Conversation context: {context}" });
186+
}
160187

161-
var requestBody = new
162-
{
163-
model,
164-
messages,
165-
temperature = ChatbotConfig.CloudNluTemperature,
166-
max_tokens = ChatbotConfig.CloudNluMaxTokens > 0 ? ChatbotConfig.CloudNluMaxTokens : 256,
167-
response_format = new { type = "json_object" }
168-
};
188+
messages.Add(new { role = "user", content = text });
189+
190+
requestBody = new
191+
{
192+
model,
193+
messages,
194+
temperature = ChatbotConfig.CloudNluTemperature,
195+
max_tokens = maxTokens,
196+
response_format = new { type = "json_object" }
197+
};
198+
}
169199

170200
var json = JsonConvert.SerializeObject(requestBody);
171201
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -174,23 +204,37 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
174204
{
175205
Content = content
176206
};
177-
request.Headers.Add("Authorization", $"Bearer {apiKey}");
178207

179-
// Azure OpenAI uses api-key header instead (system config only; a department override
180-
// is assumed OpenAI-compatible with Bearer auth).
181-
if (departmentLlm == null && ChatbotConfig.CloudNluProvider == CloudNluProviderType.AzureOpenAI)
208+
if (isAnthropic)
182209
{
183-
request.Headers.Remove("Authorization");
210+
// Anthropic authenticates with x-api-key and requires an API version header.
211+
request.Headers.Add("x-api-key", apiKey);
212+
request.Headers.Add("anthropic-version", "2023-06-01");
213+
}
214+
else if (departmentLlm == null && ChatbotConfig.CloudNluProvider == CloudNluProviderType.AzureOpenAI)
215+
{
216+
// Azure OpenAI uses an api-key header instead of Bearer auth (system config only;
217+
// a department override is assumed OpenAI-compatible with Bearer auth).
184218
request.Headers.Add("api-key", apiKey);
185219
}
220+
else
221+
{
222+
request.Headers.Add("Authorization", $"Bearer {apiKey}");
223+
}
224+
225+
// Enforce the configured timeout per request via a CancellationToken rather than the
226+
// shared client's Timeout.
227+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(
228+
ChatbotConfig.CloudNluTimeoutSeconds > 0 ? ChatbotConfig.CloudNluTimeoutSeconds : 10));
186229

187-
var response = await _httpClient.SendAsync(request);
230+
var response = await _httpClient.SendAsync(request, cts.Token);
188231
var responseBody = await response.Content.ReadAsStringAsync();
189232

190233
sw.Stop();
191234

192235
if (!response.IsSuccessStatusCode)
193236
{
237+
Logging.LogError($"Cloud NLU error from {ProviderName} (HTTP {(int)response.StatusCode}): {responseBody?.Truncate(500)}");
194238
return new NLUResult
195239
{
196240
IntentName = "unknown",
@@ -202,12 +246,13 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
202246
};
203247
}
204248

205-
var parsed = ParseOpenAiResponse(responseBody, model, sw.ElapsedMilliseconds);
249+
var parsed = ParseOpenAiResponse(responseBody, model, sw.ElapsedMilliseconds, isAnthropic);
206250
return parsed;
207251
}
208-
catch (TaskCanceledException)
252+
catch (TaskCanceledException ex)
209253
{
210254
sw.Stop();
255+
Logging.LogError($"Cloud NLU ({ProviderName}) timed out after {sw.ElapsedMilliseconds}ms: {ex.Message}");
211256
return new NLUResult
212257
{
213258
IntentName = "unknown",
@@ -220,6 +265,7 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null, i
220265
catch (Exception ex)
221266
{
222267
sw.Stop();
268+
Logging.LogException(ex, "Cloud NLU classification failed.");
223269
return new NLUResult
224270
{
225271
IntentName = "unknown",
@@ -286,40 +332,61 @@ private string ResolveModel()
286332
CloudNluProviderType.OpenAI => "gpt-4o",
287333
CloudNluProviderType.OpenAiCompatible => "gpt-4o",
288334
CloudNluProviderType.AzureOpenAI => "gpt-4",
289-
CloudNluProviderType.Anthropic => "claude-3-5-sonnet",
335+
CloudNluProviderType.Anthropic => "claude-3-5-sonnet-latest",
290336
_ => "gpt-4o"
291337
};
292338
}
293339

294-
private NLUResult ParseOpenAiResponse(string responseBody, string model, long latencyMs)
340+
private NLUResult ParseOpenAiResponse(string responseBody, string model, long latencyMs, bool isAnthropic = false)
295341
{
296342
try
297343
{
298344
var root = JObject.Parse(responseBody);
299-
var choices = root["choices"] as JArray;
300-
if (choices == null || choices.Count == 0)
345+
346+
string contentText;
347+
int? totalTokens;
348+
349+
if (isAnthropic)
301350
{
302-
return new NLUResult
351+
// Anthropic returns content as an array of blocks and reports input/output tokens
352+
// separately rather than a single total_tokens value.
353+
var contentBlocks = root["content"] as JArray;
354+
contentText = contentBlocks != null && contentBlocks.Count > 0
355+
? contentBlocks[0]?["text"]?.ToString()
356+
: null;
357+
358+
var usage = root["usage"];
359+
totalTokens = usage != null
360+
? (usage["input_tokens"]?.Value<int>() ?? 0) + (usage["output_tokens"]?.Value<int>() ?? 0)
361+
: (int?)null;
362+
}
363+
else
364+
{
365+
var choices = root["choices"] as JArray;
366+
if (choices == null || choices.Count == 0)
303367
{
304-
IntentName = "unknown",
305-
Confidence = 0,
306-
ProviderName = ProviderName,
307-
RawResponse = "Cloud NLU returned no choices.",
308-
LatencyMs = latencyMs,
309-
ModelName = model
310-
};
368+
Logging.LogError($"Cloud NLU ({ProviderName}) returned no choices.");
369+
return new NLUResult
370+
{
371+
IntentName = "unknown",
372+
Confidence = 0,
373+
ProviderName = ProviderName,
374+
RawResponse = "Cloud NLU returned no choices.",
375+
LatencyMs = latencyMs,
376+
ModelName = model
377+
};
378+
}
379+
380+
var message = choices[0]["message"];
381+
contentText = message?["content"]?.ToString();
382+
383+
var usage = root["usage"];
384+
totalTokens = usage != null ? usage["total_tokens"]?.Value<int>() : null;
311385
}
312386

313-
var message = choices[0]["message"];
314-
var contentText = message?["content"]?.ToString();
315-
316-
int? totalTokens = null;
317-
var usage = root["usage"];
318-
if (usage != null)
319-
totalTokens = usage["total_tokens"]?.Value<int>();
320-
321387
if (string.IsNullOrWhiteSpace(contentText))
322388
{
389+
Logging.LogError($"Cloud NLU ({ProviderName}) returned empty content.");
323390
return new NLUResult
324391
{
325392
IntentName = "unknown",
@@ -356,8 +423,9 @@ private NLUResult ParseOpenAiResponse(string responseBody, string model, long la
356423
TotalTokens = totalTokens
357424
};
358425
}
359-
catch (JsonException)
426+
catch (JsonException ex)
360427
{
428+
Logging.LogException(ex, "Cloud NLU returned unparseable JSON.");
361429
return new NLUResult
362430
{
363431
IntentName = "unknown",

0 commit comments

Comments
 (0)