Skip to content

Commit 36a2651

Browse files
authored
Fix Post-Session Processing Service (#64)
2 parents e657e01 + 17e560a commit 36a2651

2 files changed

Lines changed: 190 additions & 56 deletions

File tree

src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs

Lines changed: 122 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using CrestApps.Core.AI.Orchestration;
66
using CrestApps.Core.AI.Services;
77
using CrestApps.Core.Support.Json;
8+
using CrestApps.Core.Templates.Parsing;
89
using CrestApps.Core.Templates.Services;
910
using Microsoft.Extensions.AI;
1011
using Microsoft.Extensions.Logging;
@@ -22,6 +23,7 @@ public sealed class PostSessionProcessingService
2223
private readonly IAIDeploymentManager _deploymentManager;
2324
private readonly IAIToolsService _toolsService;
2425
private readonly ITemplateService _aiTemplateService;
26+
private readonly ITemplateParser _markdownTemplateParser;
2527
private readonly IServiceProvider _serviceProvider;
2628
private readonly TimeProvider _timeProvider;
2729
private readonly DefaultAIOptions _defaultOptions;
@@ -35,6 +37,7 @@ public sealed class PostSessionProcessingService
3537
/// <param name="clientFactory">The client factory.</param>
3638
/// <param name="toolsService">The tools service.</param>
3739
/// <param name="aiTemplateService">The ai template service.</param>
40+
/// <param name="templateParsers">The registered template parsers.</param>
3841
/// <param name="defaultOptions">The default options.</param>
3942
/// <param name="serviceProvider">The service provider.</param>
4043
/// <param name="timeProvider">The time provider.</param>
@@ -44,6 +47,7 @@ public PostSessionProcessingService(
4447
IAIClientFactory clientFactory,
4548
IAIToolsService toolsService,
4649
ITemplateService aiTemplateService,
50+
IEnumerable<ITemplateParser> templateParsers,
4751
DefaultAIOptions defaultOptions,
4852
IServiceProvider serviceProvider,
4953
TimeProvider timeProvider,
@@ -54,6 +58,7 @@ public PostSessionProcessingService(
5458
_deploymentManager = deploymentManager;
5559
_toolsService = toolsService;
5660
_aiTemplateService = aiTemplateService;
61+
_markdownTemplateParser = ResolveMarkdownTemplateParser(templateParsers);
5762
_serviceProvider = serviceProvider;
5863
_timeProvider = timeProvider;
5964
_defaultOptions = defaultOptions;
@@ -479,79 +484,96 @@ private async Task<Dictionary<string, PostSessionResult>> ProcessWithToolsAsync(
479484
private PostSessionProcessingResponse TryParsePostSessionResponse(string sessionId, string responseText)
480485
{
481486
// Strategy 1: Direct JSON deserialization.
482-
try
487+
if (TryDeserializePostSessionResponse(responseText, out var directResult))
483488
{
484-
var result = JsonSerializer.Deserialize<PostSessionProcessingResponse>(
485-
responseText, JSOptions.CaseInsensitive);
489+
return directResult;
490+
}
486491

487-
if (result?.Tasks != null && result.Tasks.Count > 0)
492+
if (_logger.IsEnabled(LogLevel.Debug))
493+
{
494+
_logger.LogDebug(
495+
"Post-session response for session '{SessionId}' is not valid JSON. Trying fallback extraction.",
496+
sessionId);
497+
}
498+
499+
// Strategy 2: Extract JSON from markdown code fences.
500+
var jsonBlock = JsonExtractor.ExtractFromCodeFence(responseText);
501+
502+
if (TryDeserializePostSessionResponse(jsonBlock, out var fencedResult))
503+
{
504+
if (_logger.IsEnabled(LogLevel.Debug))
488505
{
489-
return result;
506+
_logger.LogDebug(
507+
"Post-session response for session '{SessionId}' parsed successfully from code fence.",
508+
sessionId);
490509
}
510+
511+
return fencedResult;
491512
}
492-
catch (JsonException)
513+
514+
// Strategy 3: Extract the first JSON object from surrounding text.
515+
var jsonObject = JsonExtractor.ExtractJsonObject(responseText);
516+
517+
if (jsonObject != null &&
518+
jsonObject != responseText &&
519+
TryDeserializePostSessionResponse(jsonObject, out var objectResult))
493520
{
494521
if (_logger.IsEnabled(LogLevel.Debug))
495522
{
496523
_logger.LogDebug(
497-
"Post-session response for session '{SessionId}' is not valid JSON. Trying fallback extraction.",
524+
"Post-session response for session '{SessionId}' parsed successfully from embedded JSON object.",
498525
sessionId);
499526
}
527+
528+
return objectResult;
500529
}
501530

502-
// Strategy 2: Extract JSON from markdown code fences.
503-
var jsonBlock = JsonExtractor.ExtractFromCodeFence(responseText);
531+
// Strategy 4: Normalize markdown/front matter, then retry the same lenient extraction.
532+
var normalizedBody = _markdownTemplateParser.Parse(responseText).Body?.Trim();
504533

505-
if (jsonBlock != null)
534+
if (!string.IsNullOrWhiteSpace(normalizedBody) &&
535+
!string.Equals(normalizedBody, responseText.Trim(), StringComparison.Ordinal))
506536
{
507-
try
537+
if (TryDeserializePostSessionResponse(normalizedBody, out var normalizedResult))
508538
{
509-
var result = JsonSerializer.Deserialize<PostSessionProcessingResponse>(
510-
jsonBlock, JSOptions.CaseInsensitive);
511-
512-
if (result?.Tasks != null && result.Tasks.Count > 0)
539+
if (_logger.IsEnabled(LogLevel.Debug))
513540
{
514-
if (_logger.IsEnabled(LogLevel.Debug))
515-
{
516-
_logger.LogDebug(
517-
"Post-session response for session '{SessionId}' parsed successfully from code fence.",
518-
sessionId);
519-
}
520-
521-
return result;
541+
_logger.LogDebug(
542+
"Post-session response for session '{SessionId}' parsed successfully from normalized markdown body.",
543+
sessionId);
522544
}
545+
546+
return normalizedResult;
523547
}
524-
catch (JsonException)
525-
{
526-
// Code fence content wasn't valid JSON either, continue to next strategy.
527-
}
528-
}
529548

530-
// Strategy 3: Extract the first JSON object from surrounding text.
531-
var jsonObject = JsonExtractor.ExtractJsonObject(responseText);
549+
var normalizedJsonBlock = JsonExtractor.ExtractFromCodeFence(normalizedBody);
532550

533-
if (jsonObject != null && jsonObject != responseText)
534-
{
535-
try
551+
if (TryDeserializePostSessionResponse(normalizedJsonBlock, out var normalizedFencedResult))
536552
{
537-
var result = JsonSerializer.Deserialize<PostSessionProcessingResponse>(
538-
jsonObject, JSOptions.CaseInsensitive);
539-
540-
if (result?.Tasks != null && result.Tasks.Count > 0)
553+
if (_logger.IsEnabled(LogLevel.Debug))
541554
{
542-
if (_logger.IsEnabled(LogLevel.Debug))
543-
{
544-
_logger.LogDebug(
545-
"Post-session response for session '{SessionId}' parsed successfully from embedded JSON object.",
546-
sessionId);
547-
}
548-
549-
return result;
555+
_logger.LogDebug(
556+
"Post-session response for session '{SessionId}' parsed successfully from normalized markdown code fence.",
557+
sessionId);
550558
}
559+
560+
return normalizedFencedResult;
551561
}
552-
catch (JsonException)
562+
563+
var normalizedJsonObject = JsonExtractor.ExtractJsonObject(normalizedBody);
564+
565+
if (normalizedJsonObject != null &&
566+
normalizedJsonObject != normalizedBody &&
567+
TryDeserializePostSessionResponse(normalizedJsonObject, out var normalizedObjectResult))
553568
{
554-
// Extracted text wasn't valid JSON either.
569+
if (_logger.IsEnabled(LogLevel.Debug))
570+
{
571+
_logger.LogDebug(
572+
"Post-session response for session '{SessionId}' parsed successfully from normalized markdown embedded JSON object.",
573+
sessionId);
574+
}
575+
576+
return normalizedObjectResult;
555577
}
556578
}
557579

@@ -617,7 +639,7 @@ private async Task<Dictionary<string, PostSessionResult>> TryRecoverStructuredTo
617639
CreateResponseLogPreview(recoveryResponseText));
618640
}
619641

620-
PostSessionProcessingResponse result;
642+
PostSessionProcessingResponse result = null;
621643

622644
try
623645
{
@@ -631,8 +653,6 @@ private async Task<Dictionary<string, PostSessionResult>> TryRecoverStructuredTo
631653
"Structured recovery for post-session tool response on session '{SessionId}' did not return JSON content.",
632654
sessionId);
633655
}
634-
635-
return null;
636656
}
637657
catch (JsonException)
638658
{
@@ -642,11 +662,24 @@ private async Task<Dictionary<string, PostSessionResult>> TryRecoverStructuredTo
642662
"Structured recovery for post-session tool response on session '{SessionId}' returned invalid JSON content.",
643663
sessionId);
644664
}
665+
}
645666

646-
return null;
667+
if (result?.Tasks is { Count: > 0 })
668+
{
669+
if (_logger.IsEnabled(LogLevel.Debug))
670+
{
671+
_logger.LogDebug(
672+
"Structured recovery for post-session tool response on session '{SessionId}' succeeded with {TaskCount} task result(s).",
673+
sessionId,
674+
result.Tasks.Count);
675+
}
676+
677+
return ApplyResults(tasks, result.Tasks);
647678
}
648679

649-
if (result?.Tasks is null || result.Tasks.Count == 0)
680+
var recoveredFromText = TryParsePostSessionResponse(sessionId, recoveryResponseText);
681+
682+
if (recoveredFromText?.Tasks is null || recoveredFromText.Tasks.Count == 0)
650683
{
651684
if (_logger.IsEnabled(LogLevel.Debug))
652685
{
@@ -661,12 +694,46 @@ private async Task<Dictionary<string, PostSessionResult>> TryRecoverStructuredTo
661694
if (_logger.IsEnabled(LogLevel.Debug))
662695
{
663696
_logger.LogDebug(
664-
"Structured recovery for post-session tool response on session '{SessionId}' succeeded with {TaskCount} task result(s).",
697+
"Structured recovery for post-session tool response on session '{SessionId}' succeeded by parsing assistant text with {TaskCount} task result(s).",
665698
sessionId,
666-
result.Tasks.Count);
699+
recoveredFromText.Tasks.Count);
700+
}
701+
702+
return ApplyResults(tasks, recoveredFromText.Tasks);
703+
}
704+
705+
private static bool TryDeserializePostSessionResponse(
706+
string responseText,
707+
out PostSessionProcessingResponse response)
708+
{
709+
if (string.IsNullOrWhiteSpace(responseText))
710+
{
711+
response = null;
712+
return false;
713+
}
714+
715+
try
716+
{
717+
response = JsonSerializer.Deserialize<PostSessionProcessingResponse>(
718+
responseText,
719+
JSOptions.CaseInsensitive);
720+
721+
return response?.Tasks is { Count: > 0 };
722+
}
723+
catch (JsonException)
724+
{
725+
response = null;
726+
return false;
667727
}
728+
}
729+
730+
private static ITemplateParser ResolveMarkdownTemplateParser(IEnumerable<ITemplateParser> templateParsers)
731+
{
732+
ArgumentNullException.ThrowIfNull(templateParsers);
668733

669-
return ApplyResults(tasks, result.Tasks);
734+
return templateParsers.FirstOrDefault(parser =>
735+
parser.SupportedExtensions.Any(extension => string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)))
736+
?? throw new InvalidOperationException("No markdown template parser is registered for post-session response recovery.");
670737
}
671738

672739
private Dictionary<string, PostSessionResult> CreateFailedResults(

tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using CrestApps.Core.AI.Clients;
44
using CrestApps.Core.AI.Deployments;
55
using CrestApps.Core.AI.Models;
6+
using CrestApps.Core.Templates.Parsing;
67
using CrestApps.Core.Templates.Services;
78
using Microsoft.Extensions.AI;
89
using Microsoft.Extensions.Logging.Abstractions;
@@ -518,7 +519,16 @@ private static PostSessionProcessingService CreateService(IChatClient chatClient
518519
MaximumIterationsPerRequest = 10,
519520
};
520521

521-
return new PostSessionProcessingService(mockClientFactory.Object, toolsService ?? mockToolsService.Object, templateService ?? mockTemplateService.Object, defaultOptions, new Mock<IServiceProvider>().Object, TimeProvider.System, NullLoggerFactory.Instance, mockDeploymentManager.Object);
522+
return new PostSessionProcessingService(
523+
mockClientFactory.Object,
524+
toolsService ?? mockToolsService.Object,
525+
templateService ?? mockTemplateService.Object,
526+
[new DefaultMarkdownTemplateParser()],
527+
defaultOptions,
528+
new Mock<IServiceProvider>().Object,
529+
TimeProvider.System,
530+
NullLoggerFactory.Instance,
531+
mockDeploymentManager.Object);
522532
}
523533

524534
/// <summary>
@@ -675,6 +685,63 @@ public async Task ProcessAsync_WithTools_WhenResponseIsTruncatedJson_ShouldRecov
675685
mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
676686
}
677687

688+
[Fact]
689+
public async Task ProcessAsync_WithTools_WhenStructuredRetryReturnsMarkdownWrappedJson_ShouldRecoverFromAssistantText()
690+
{
691+
// Arrange: the structured retry still returns markdown-wrapped text even though the JSON is recoverable.
692+
var profile = CreateProfile();
693+
profile.AlterSettings<AIProfilePostSessionSettings>(s =>
694+
{
695+
s.EnablePostSessionProcessing = true;
696+
s.PostSessionTasks = [new PostSessionTask
697+
{
698+
Name = "summary",
699+
Type = PostSessionTaskType.Semantic,
700+
Instructions = "Summarize the conversation.",
701+
}, ];
702+
s.ToolNames = ["sendEmail"];
703+
});
704+
var session = CreateSession();
705+
var prompts = CreatePrompts();
706+
var mockTool = new TestAIFunction("sendEmail");
707+
var mockToolsService = new Mock<IAIToolsService>();
708+
mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool);
709+
var mockChatClient = new Mock<IChatClient>();
710+
mockChatClient.SetupSequence(c => c
711+
.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
712+
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{")))
713+
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """
714+
---
715+
Title: Post-session result
716+
---
717+
```json
718+
{
719+
"tasks": [
720+
{
721+
"name": "summary",
722+
"value": "Customer requested a quote and the assistant collected the zip code."
723+
}
724+
]
725+
}
726+
```
727+
""")));
728+
var mockTemplateService = new Mock<ITemplateService>();
729+
mockTemplateService.Setup(t => t
730+
.RenderAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object>>(), It.IsAny<CancellationToken>()))
731+
.ReturnsAsync("Rendered prompt");
732+
var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object);
733+
734+
// Act
735+
var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken);
736+
737+
// Assert: the recovery pass should fall back to the assistant text and extract the structured result.
738+
Assert.NotNull(result);
739+
Assert.True(result.ContainsKey("summary"));
740+
Assert.Equal("Customer requested a quote and the assistant collected the zip code.", result["summary"].Value);
741+
Assert.Equal(PostSessionTaskResultStatus.Succeeded, result["summary"].Status);
742+
mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
743+
}
744+
678745
[Fact]
679746
public async Task ProcessAsync_WithTools_WhenSingleSemanticTaskAndNonJsonResponse_ShouldReturnFailedResult()
680747
{

0 commit comments

Comments
 (0)