Skip to content

Commit 517b748

Browse files
authored
Update Post-Session Analysis Prompt (#389)
1 parent 6ae8050 commit 517b748

File tree

27 files changed

+538
-118
lines changed

27 files changed

+538
-118
lines changed

.github/copilot-instructions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ gulp rebuild
5151
dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false
5252
```
5353

54+
**Important for Copilot skills**: In a fresh environment, build the project early and then check for newly generated skills under `.agents\skills`. One of the packages can create `.agents\skills` on the first build, and those generated skills may contain important, up-to-date capabilities that are not visible before the build runs.
55+
5456
**If build fails with NU1301 errors** about `nuget.cloudsmith.io`, this is expected in restricted environments. Document this limitation rather than attempting workarounds.
5557

5658
#### Unit Tests (Requires Successful Build)
@@ -201,6 +203,7 @@ tests/
201203
- **MCP Server**: `CrestApps.OrchardCore.AI.Mcp` - exposes Orchard Core content as MCP resources
202204
- **AI Agents**: `CrestApps.OrchardCore.AI.Agent` - defines reusable AI agents/tools
203205
- **Provider modules**: `CrestApps.OrchardCore.OpenAI`, `CrestApps.OrchardCore.OpenAI.Azure`, `CrestApps.OrchardCore.Ollama`, `CrestApps.OrchardCore.AzureAIInference`
206+
- **AI Prompt Templates**: Never hardcode AI system prompts or prompt-style recovery instructions in C# code. Store them in `AITemplates/Prompts/*.md`, add a constant in `AITemplateIds`, and render them through `IAITemplateService`.
204207

205208
### Working with Omnichannel Modules
206209
- **Base Module**: `CrestApps.OrchardCore.Omnichannel` - unified communication layer
@@ -279,6 +282,7 @@ If CloudSmith is inaccessible, only asset builds and code analysis are possible.
279282
- **Code Analysis**: `AnalysisLevel` is set to `latest-Recommended`
280283
- **Implicit usings**: Enabled globally
281284
- **Date/time**: Never use `DateTime.UtcNow`. Always inject `IClock` in the constructor (e.g., `IClock clock`) and store it as `private readonly IClock _clock = clock;`, then call `_clock.UtcNow` in methods
285+
- **Localization extraction**: When using `ILocalizer`, the property/variable must be named `S`, and localized strings must use the literal pattern `S["This is a localized string"]`. Do not use variables inside the brackets because extraction tooling looks specifically for `S["..."]`.
282286
- **One type per file**: Every public type must live in its own file. The file name must always match the type name (e.g., `MyService.cs` for `class MyService`)
283287
- **sealed classes**: Seal all classes by default (`sealed class`), **except** ViewModel classes that are consumed by any Orchard Core display driver — those must remain unsealed because the framework creates runtime proxies for them and proxies cannot be created from sealed types
284288

@@ -549,4 +553,4 @@ After completing any code change, always clean up:
549553
- [Orchard Core GitHub](https://github.com/OrchardCMS/OrchardCore)
550554
- [Project Repository](https://github.com/CrestApps/CrestApps.OrchardCore)
551555
- [Contributing Guidelines](.github/CONTRIBUTING.md)
552-
- [MIT License](https://opensource.org/licenses/MIT)
556+
- [MIT License](https://opensource.org/licenses/MIT)

src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,10 @@ private async Task ProcessChatPromptAsync(ChannelWriter<CompletionPartialMessage
427427

428428
(var chatSession, var isNew) = await GetSessionAsync(services, sessionId, profile, prompt);
429429

430+
// Ensure the caller joins the session group as soon as the effective session is known,
431+
// even when the session was created implicitly by SendMessage streaming.
432+
await Groups.AddToGroupAsync(Context.ConnectionId, GetSessionGroupName(chatSession.SessionId), cancellationToken);
433+
430434
var utcNow = clock.UtcNow;
431435

432436
// Handle session reopen if closed.

src/Core/CrestApps.OrchardCore.AI.Chat.Interactions.Core/Hubs/ChatInteractionHub.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,10 @@ private async Task HandlePromptAsync(ChannelWriter<CompletionPartialMessage> wri
410410

411411
prompt = prompt.Trim();
412412

413+
// Ensure the caller joins the interaction group before any deferred webhook
414+
// notifications or live-agent messages are delivered.
415+
await Groups.AddToGroupAsync(Context.ConnectionId, GetInteractionGroupName(interaction.ItemId), cancellationToken);
416+
413417
var promptStore = services.GetRequiredService<IChatInteractionPromptStore>();
414418
var handlerResolver = services.GetRequiredService<IChatResponseHandlerResolver>();
415419
var citationCollector = services.GetRequiredService<CitationReferenceCollector>();

src/Core/CrestApps.OrchardCore.AI.Core/Models/DefaultAIDeploymentSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ namespace CrestApps.OrchardCore.AI.Core.Models;
88
/// </summary>
99
public sealed class DefaultAIDeploymentSettings
1010
{
11+
/// <summary>
12+
/// Gets or sets the default chat deployment identifier.
13+
/// Used globally for chat sessions and chat interactions when no specific chat deployment is configured.
14+
/// </summary>
15+
public string DefaultChatDeploymentId { get; set; }
16+
1117
/// <summary>
1218
/// Gets or sets the default utility deployment identifier.
1319
/// Used globally for auxiliary tasks such as planning, intent detection,

src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIDeploymentManager.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,12 @@ private async ValueTask<string> GetGlobalDefaultIdAsync(AIDeploymentType type)
124124

125125
return type switch
126126
{
127+
AIDeploymentType.Chat => settings.DefaultChatDeploymentId,
127128
AIDeploymentType.Utility => settings.DefaultUtilityDeploymentId,
128129
AIDeploymentType.Embedding => settings.DefaultEmbeddingDeploymentId,
129130
AIDeploymentType.Image => settings.DefaultImageDeploymentId,
130131
AIDeploymentType.SpeechToText => settings.DefaultSpeechToTextDeploymentId,
132+
AIDeploymentType.TextToSpeech => settings.DefaultTextToSpeechDeploymentId,
131133
_ => null,
132134
};
133135
}

src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultSpeechVoicePresenter.cs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,18 @@ public async Task<IEnumerable<SelectListItem>> GetVoiceMenuItemsAsync(string dep
4343
var speechVoices = await _clientFactory.GetSpeechVoicesAsync(deployment);
4444

4545
var supportedCultures = await _localizationService.GetSupportedCulturesAsync();
46-
var supportedSet = new HashSet<string>(supportedCultures, StringComparer.OrdinalIgnoreCase);
46+
var supportedSet = SpeechVoiceLocalizationHelper.CreateAllowedCultures(
47+
supportedCultures,
48+
CultureInfo.CurrentCulture,
49+
CultureInfo.CurrentUICulture);
4750

4851
var voices = new List<SelectListItem>();
4952

5053
foreach (var voiceGroup in speechVoices
51-
.Where(v => string.IsNullOrEmpty(v.Language) || supportedSet.Contains(v.Language))
54+
.Where(v => SpeechVoiceLocalizationHelper.IsLanguageAllowed(v.Language, supportedSet))
5255
.OrderBy(v => v.Language)
5356
.ThenBy(v => v.Name)
54-
.GroupBy(v => GetCultureDisplayName(v.Language) ?? "Unknown"))
57+
.GroupBy(v => SpeechVoiceLocalizationHelper.GetCultureDisplayName(v.Language) ?? "Unknown"))
5558
{
5659
var group = new SelectListGroup { Name = voiceGroup.Key };
5760

@@ -71,20 +74,4 @@ public async Task<IEnumerable<SelectListItem>> GetVoiceMenuItemsAsync(string dep
7174
}
7275
}
7376

74-
private static string GetCultureDisplayName(string language)
75-
{
76-
if (string.IsNullOrEmpty(language))
77-
{
78-
return null;
79-
}
80-
81-
try
82-
{
83-
return CultureInfo.GetCultureInfo(language).DisplayName;
84-
}
85-
catch (CultureNotFoundException)
86-
{
87-
return language;
88-
}
89-
}
9077
}

src/Core/CrestApps.OrchardCore.AI.Core/Services/PostSessionProcessingService.cs

Lines changed: 136 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -389,32 +389,39 @@ private async Task<Dictionary<string, PostSessionResult>> ProcessWithToolsAsync(
389389
_logger.LogDebug(
390390
"Post-session tools raw response for session '{SessionId}': '{ResponseText}'.",
391391
sessionId,
392-
responseText?.Length > 2000 ? responseText[..2000] + "..." : responseText ?? "(empty)");
392+
CreateResponseLogPreview(responseText));
393393
}
394394

395-
if (string.IsNullOrEmpty(responseText))
395+
if (!string.IsNullOrEmpty(responseText))
396396
{
397-
if (_logger.IsEnabled(LogLevel.Debug))
397+
var result = TryParsePostSessionResponse(sessionId, responseText);
398+
399+
if (result?.Tasks != null && result.Tasks.Count > 0)
398400
{
399-
_logger.LogDebug(
400-
"Post-session tools response for session '{SessionId}' has no final text content. Tools may have executed as side effects.",
401-
sessionId);
401+
return ApplyResults(tasks, result.Tasks);
402402
}
403-
404-
return null;
403+
}
404+
else if (_logger.IsEnabled(LogLevel.Debug))
405+
{
406+
_logger.LogDebug(
407+
"Post-session tools response for session '{SessionId}' has no final text content. Attempting structured recovery from tool messages.",
408+
sessionId);
405409
}
406410

407-
// Try to parse the response as structured JSON using multiple strategies.
408-
var result = TryParsePostSessionResponse(sessionId, responseText);
411+
var recoveredResults = await TryRecoverStructuredToolsResponseAsync(
412+
sessionId,
413+
chatClient,
414+
messages,
415+
response.Messages,
416+
tasks,
417+
cancellationToken);
409418

410-
if (result?.Tasks != null && result.Tasks.Count > 0)
419+
if (recoveredResults is not null && recoveredResults.Count > 0)
411420
{
412-
return ApplyResults(tasks, result.Tasks);
421+
return recoveredResults;
413422
}
414423

415-
// Fallback: when the model produces text but no structured task results,
416-
// use the response text as the value for semantic tasks.
417-
return TryExtractFromResponseText(sessionId, tasks, responseText);
424+
return CreateFailedResults(sessionId, tasks, responseText);
418425
}
419426

420427
/// <summary>
@@ -513,66 +520,134 @@ private PostSessionProcessingResponse TryParsePostSessionResponse(string session
513520
return null;
514521
}
515522

516-
/// <summary>
517-
/// Fallback: when the model returns text that cannot be parsed as structured JSON,
518-
/// use the response text directly as the value for semantic tasks.
519-
/// Only applies when all pending tasks are semantic type.
520-
/// </summary>
521-
private Dictionary<string, PostSessionResult> TryExtractFromResponseText(
523+
private async Task<Dictionary<string, PostSessionResult>> TryRecoverStructuredToolsResponseAsync(
522524
string sessionId,
525+
IChatClient chatClient,
526+
List<ChatMessage> requestMessages,
527+
IList<ChatMessage> responseMessages,
523528
List<PostSessionTask> tasks,
524-
string responseText)
529+
CancellationToken cancellationToken)
525530
{
526-
var semanticTasks = tasks.Where(t => t.Type == PostSessionTaskType.Semantic).ToList();
531+
var followUpMessages = new List<ChatMessage>(requestMessages);
532+
var trailingAssistantText = responseMessages?
533+
.LastOrDefault(message => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text));
527534

528-
if (semanticTasks.Count == 0)
535+
if (responseMessages is not null)
536+
{
537+
foreach (var responseMessage in responseMessages)
538+
{
539+
if (ReferenceEquals(responseMessage, trailingAssistantText))
540+
{
541+
continue;
542+
}
543+
544+
followUpMessages.Add(responseMessage);
545+
}
546+
}
547+
548+
if (_logger.IsEnabled(LogLevel.Debug))
549+
{
550+
_logger.LogDebug(
551+
"Attempting structured recovery for post-session tool response on session '{SessionId}' using the original post-session analysis context. TaskCount={TaskCount}.",
552+
sessionId,
553+
tasks.Count);
554+
}
555+
556+
var response = await chatClient.GetResponseAsync<PostSessionProcessingResponse>(followUpMessages, new ChatOptions
557+
{
558+
Temperature = 0f,
559+
}, null, cancellationToken);
560+
561+
var recoveryResponseText = response.Messages?
562+
.LastOrDefault(message => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text))
563+
?.Text?.Trim();
564+
565+
if (_logger.IsEnabled(LogLevel.Debug))
566+
{
567+
_logger.LogDebug(
568+
"Post-session structured recovery raw response for session '{SessionId}': '{ResponseText}'.",
569+
sessionId,
570+
CreateResponseLogPreview(recoveryResponseText));
571+
}
572+
573+
PostSessionProcessingResponse result;
574+
575+
try
576+
{
577+
result = response.Result;
578+
}
579+
catch (InvalidOperationException)
529580
{
530581
if (_logger.IsEnabled(LogLevel.Debug))
531582
{
532583
_logger.LogDebug(
533-
"Post-session response for session '{SessionId}' produced no structured results and has no semantic tasks for text fallback.",
584+
"Structured recovery for post-session tool response on session '{SessionId}' did not return JSON content.",
534585
sessionId);
535586
}
536587

537588
return null;
538589
}
539-
540-
// For a single semantic task, use the entire response text as its value.
541-
if (semanticTasks.Count == 1 && tasks.Count == 1)
590+
catch (JsonException)
542591
{
543-
var task = semanticTasks[0];
544-
var now = _clock.UtcNow;
545-
546592
if (_logger.IsEnabled(LogLevel.Debug))
547593
{
548594
_logger.LogDebug(
549-
"Post-session task '{TaskName}' for session '{SessionId}' using response text as fallback value (length={Length}).",
550-
task.Name,
551-
sessionId,
552-
responseText.Length);
595+
"Structured recovery for post-session tool response on session '{SessionId}' returned invalid JSON content.",
596+
sessionId);
553597
}
554598

555-
return new Dictionary<string, PostSessionResult>(StringComparer.OrdinalIgnoreCase)
599+
return null;
600+
}
601+
602+
if (result?.Tasks is null || result.Tasks.Count == 0)
603+
{
604+
if (_logger.IsEnabled(LogLevel.Debug))
556605
{
557-
[task.Name] = new PostSessionResult
558-
{
559-
Name = task.Name,
560-
Value = responseText,
561-
Status = PostSessionTaskResultStatus.Succeeded,
562-
ProcessedAtUtc = now,
563-
},
564-
};
606+
_logger.LogDebug(
607+
"Structured recovery for post-session tool response on session '{SessionId}' returned no task results.",
608+
sessionId);
609+
}
610+
611+
return null;
565612
}
566613

567614
if (_logger.IsEnabled(LogLevel.Debug))
568615
{
569616
_logger.LogDebug(
570-
"Post-session response for session '{SessionId}' produced no structured results. Cannot apply text fallback to {TaskCount} tasks.",
617+
"Structured recovery for post-session tool response on session '{SessionId}' succeeded with {TaskCount} task result(s).",
571618
sessionId,
572-
tasks.Count);
619+
result.Tasks.Count);
573620
}
574621

575-
return null;
622+
return ApplyResults(tasks, result.Tasks);
623+
}
624+
625+
private Dictionary<string, PostSessionResult> CreateFailedResults(
626+
string sessionId,
627+
List<PostSessionTask> tasks,
628+
string responseText)
629+
{
630+
var now = _clock.UtcNow;
631+
var errorMessage = string.IsNullOrWhiteSpace(responseText)
632+
? "Tool execution completed, but the AI response did not contain the required structured JSON results."
633+
: "The AI response could not be parsed as structured JSON after tool execution.";
634+
635+
_logger.LogWarning(
636+
"Post-session tool response for session '{SessionId}' failed structured parsing. Marking {TaskCount} task(s) as failed. ResponseLength={ResponseLength}.",
637+
sessionId,
638+
tasks.Count,
639+
responseText?.Length ?? 0);
640+
641+
return tasks.ToDictionary(
642+
task => task.Name,
643+
task => new PostSessionResult
644+
{
645+
Name = task.Name,
646+
Status = PostSessionTaskResultStatus.Failed,
647+
ErrorMessage = errorMessage,
648+
ProcessedAtUtc = now,
649+
},
650+
StringComparer.OrdinalIgnoreCase);
576651
}
577652

578653
/// <summary>
@@ -607,6 +682,20 @@ private static string ExtractJsonObject(string text)
607682
return text[start..(end + 1)];
608683
}
609684

685+
private static string CreateResponseLogPreview(string responseText)
686+
{
687+
if (string.IsNullOrEmpty(responseText))
688+
{
689+
return "(empty)";
690+
}
691+
692+
var normalized = responseText
693+
.Replace("\r", "\\r", StringComparison.Ordinal)
694+
.Replace("\n", "\\n", StringComparison.Ordinal);
695+
696+
return normalized.Length > 2000 ? normalized[..2000] + "..." : normalized;
697+
}
698+
610699
private Dictionary<string, PostSessionResult> ApplyResults(
611700
List<PostSessionTask> tasks,
612701
List<PostSessionTaskResult> results)

0 commit comments

Comments
 (0)