Skip to content

Commit 83a5f13

Browse files
committed
feat(T085-T089): Response formatting - strip labels/citations, render markdown
- Add ResponseTextSanitizer to strip [GROUNDED]/[GENERAL GUIDANCE] labels and citation markers - Integrate sanitizer in AgentFrameworkChatAgent after answer type extraction - Add MarkdownRenderer.razor for bold/italic/list rendering in assistant messages - Wire MarkdownRenderer in Chat.razor for assistant message content - Add unit test project with 33 tests for ResponseTextSanitizer All tests pass (33/33).
1 parent 4a91315 commit 83a5f13

8 files changed

Lines changed: 509 additions & 3 deletions

File tree

specs/001-health-plan-chat/tasks.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@
191191

192192
**Purpose**: Infrastructure, deployment, and demo hardening. **These tasks are optional stretch goals** — the MVP is functional without them.
193193

194+
### Response Formatting (US1/US2 Polish)
195+
196+
- [X] T085 [P] Strip answer type labels (`**[GROUNDED]**`, `**[GENERAL GUIDANCE]**`) from response text after extraction in `src/backend/HealthPlanChat.Infrastructure.AgentFramework/AgentFrameworkChatAgent.cs` (label is already captured in `AnswerType`; raw marker should not appear in `AnswerText`)
197+
- [X] T086 [P] Strip/replace citation markers (e.g., `【3:0†source】`) from response text in `src/backend/HealthPlanChat.Infrastructure.AgentFramework/AgentFrameworkChatAgent.cs` (citations are already captured in `References`; raw markers should not appear in `AnswerText`)
198+
- [X] T087 [P] Add markdown rendering for assistant messages in `src/frontend/HealthPlanChat.Web/Components/MarkdownRenderer.razor` (render `**bold**`, `*italic*`, lists, etc. using a lightweight markdown parser like Markdig or simple regex for bold/italic only)
199+
- [X] T088 Update `Chat.razor` to use `MarkdownRenderer` for assistant message content in `src/frontend/HealthPlanChat.Web/Pages/Chat.razor`
200+
- [X] T089 Add unit tests for response text sanitization (label stripping, citation marker removal) in `src/backend/HealthPlanChat.Infrastructure.AgentFramework.UnitTests/` or existing test project
201+
202+
### Documentation & Configuration
203+
194204
- [ ] T060 Add runtime configuration docs in `specs/001-health-plan-chat/quickstart.md` (Azure env vars and expected settings)
195205
- [ ] T061 Validate Quickstart end-to-end and update `specs/001-health-plan-chat/quickstart.md` with final commands (include a short demo checklist using `data/demo-questions.json` for SC-001 spot-checks)
196206
- [ ] T062 Add Core unit tests for `ChatInteractor` (labeling: Grounded vs GeneralGuidance; references shape; deterministic behavior) in `src/backend/HealthPlanChat.Core.UnitTests/UseCases/Chat/ChatInteractorTests.cs`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="coverlet.collector" />
13+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
14+
<PackageReference Include="xunit" />
15+
<PackageReference Include="xunit.runner.visualstudio" />
16+
<PackageReference Include="FluentAssertions" />
17+
<PackageReference Include="Moq" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<ProjectReference Include="..\HealthPlanChat.Infrastructure.AgentFramework\HealthPlanChat.Infrastructure.AgentFramework.csproj" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<Using Include="Xunit" />
26+
<Using Include="FluentAssertions" />
27+
</ItemGroup>
28+
29+
</Project>
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
namespace HealthPlanChat.Infrastructure.AgentFramework.UnitTests;
2+
3+
public class ResponseTextSanitizerTests
4+
{
5+
[Fact]
6+
public void Sanitize_NullInput_ReturnsNull()
7+
{
8+
// Act
9+
var result = ResponseTextSanitizer.Sanitize(null!);
10+
11+
// Assert
12+
result.Should().BeNull();
13+
}
14+
15+
[Fact]
16+
public void Sanitize_EmptyInput_ReturnsEmpty()
17+
{
18+
// Act
19+
var result = ResponseTextSanitizer.Sanitize(string.Empty);
20+
21+
// Assert
22+
result.Should().BeEmpty();
23+
}
24+
25+
[Fact]
26+
public void Sanitize_WhitespaceInput_ReturnsEmpty()
27+
{
28+
// Act
29+
var result = ResponseTextSanitizer.Sanitize(" ");
30+
31+
// Assert
32+
result.Should().BeEmpty();
33+
}
34+
35+
[Theory]
36+
[InlineData("**[GROUNDED]** Here is the answer.", "Here is the answer.")]
37+
[InlineData("[GROUNDED] Here is the answer.", "Here is the answer.")]
38+
[InlineData("*[GROUNDED]* Here is the answer.", "Here is the answer.")]
39+
[InlineData("**[grounded]** Here is the answer.", "Here is the answer.")]
40+
public void Sanitize_StripsGroundedLabel(string input, string expected)
41+
{
42+
// Act
43+
var result = ResponseTextSanitizer.Sanitize(input);
44+
45+
// Assert
46+
result.Should().Be(expected);
47+
}
48+
49+
[Theory]
50+
[InlineData("**[GENERAL GUIDANCE]** I cannot find that info.", "I cannot find that info.")]
51+
[InlineData("[GENERAL GUIDANCE] I cannot find that info.", "I cannot find that info.")]
52+
[InlineData("*[GENERAL GUIDANCE]* I cannot find that info.", "I cannot find that info.")]
53+
[InlineData("**[general guidance]** I cannot find that info.", "I cannot find that info.")]
54+
public void Sanitize_StripsGeneralGuidanceLabel(string input, string expected)
55+
{
56+
// Act
57+
var result = ResponseTextSanitizer.Sanitize(input);
58+
59+
// Assert
60+
result.Should().Be(expected);
61+
}
62+
63+
[Theory]
64+
[InlineData("The deductible is $500【3:0†source】.", "The deductible is $500.")]
65+
[InlineData("Coverage includes therapy【1:2†source】 and checkups【2:1†source】.", "Coverage includes therapy and checkups.")]
66+
[InlineData("See plan details【10:15†source】.", "See plan details.")]
67+
public void Sanitize_StripsCitationMarkers(string input, string expected)
68+
{
69+
// Act
70+
var result = ResponseTextSanitizer.Sanitize(input);
71+
72+
// Assert
73+
result.Should().Be(expected);
74+
}
75+
76+
[Theory]
77+
[InlineData("The deductible is $500 [doc_0].", "The deductible is $500.")]
78+
[InlineData("Coverage [doc_1] and benefits [doc_2].", "Coverage and benefits.")]
79+
public void Sanitize_StripsDocCitationMarkers(string input, string expected)
80+
{
81+
// Act
82+
var result = ResponseTextSanitizer.Sanitize(input);
83+
84+
// Assert
85+
result.Should().Be(expected);
86+
}
87+
88+
[Theory]
89+
[InlineData("The deductible is $500 [1].", "The deductible is $500.")]
90+
[InlineData("Coverage [1] and benefits [2].", "Coverage and benefits.")]
91+
public void Sanitize_StripsNumberedCitationMarkers(string input, string expected)
92+
{
93+
// Act
94+
var result = ResponseTextSanitizer.Sanitize(input);
95+
96+
// Assert
97+
result.Should().Be(expected);
98+
}
99+
100+
[Fact]
101+
public void Sanitize_PreservesMarkdownLinks()
102+
{
103+
// Arrange
104+
var input = "See [this link](https://example.com) for details.";
105+
106+
// Act
107+
var result = ResponseTextSanitizer.Sanitize(input);
108+
109+
// Assert - markdown links should be preserved
110+
result.Should().Be("See [this link](https://example.com) for details.");
111+
}
112+
113+
[Fact]
114+
public void Sanitize_CombinedLabelAndCitations()
115+
{
116+
// Arrange
117+
var input = "**[GROUNDED]** The deductible is $500【3:0†source】. Coverage includes therapy【1:2†source】.";
118+
var expected = "The deductible is $500. Coverage includes therapy.";
119+
120+
// Act
121+
var result = ResponseTextSanitizer.Sanitize(input);
122+
123+
// Assert
124+
result.Should().Be(expected);
125+
}
126+
127+
[Fact]
128+
public void Sanitize_PreservesRegularMarkdown()
129+
{
130+
// Arrange
131+
var input = "The **deductible** is *important*.";
132+
133+
// Act
134+
var result = ResponseTextSanitizer.Sanitize(input);
135+
136+
// Assert - regular markdown should be preserved for frontend rendering
137+
result.Should().Be("The **deductible** is *important*.");
138+
}
139+
140+
[Fact]
141+
public void Sanitize_CleansUpDoubleSpaces()
142+
{
143+
// Arrange
144+
var input = "**[GROUNDED]** Here is the answer.";
145+
146+
// Act
147+
var result = ResponseTextSanitizer.Sanitize(input);
148+
149+
// Assert
150+
result.Should().Be("Here is the answer.");
151+
}
152+
153+
[Fact]
154+
public void Sanitize_TrimsLeadingAndTrailingWhitespace()
155+
{
156+
// Arrange
157+
var input = " **[GROUNDED]** Here is the answer. ";
158+
159+
// Act
160+
var result = ResponseTextSanitizer.Sanitize(input);
161+
162+
// Assert
163+
result.Should().Be("Here is the answer.");
164+
}
165+
166+
[Fact]
167+
public void ContainsGroundedLabel_ReturnsTrue_WhenPresent()
168+
{
169+
// Arrange
170+
var input = "**[GROUNDED]** Answer here.";
171+
172+
// Act
173+
var result = ResponseTextSanitizer.ContainsGroundedLabel(input);
174+
175+
// Assert
176+
result.Should().BeTrue();
177+
}
178+
179+
[Fact]
180+
public void ContainsGroundedLabel_ReturnsFalse_WhenNotPresent()
181+
{
182+
// Arrange
183+
var input = "Just a regular answer.";
184+
185+
// Act
186+
var result = ResponseTextSanitizer.ContainsGroundedLabel(input);
187+
188+
// Assert
189+
result.Should().BeFalse();
190+
}
191+
192+
[Fact]
193+
public void ContainsGeneralGuidanceLabel_ReturnsTrue_WhenPresent()
194+
{
195+
// Arrange
196+
var input = "**[GENERAL GUIDANCE]** I cannot find that.";
197+
198+
// Act
199+
var result = ResponseTextSanitizer.ContainsGeneralGuidanceLabel(input);
200+
201+
// Assert
202+
result.Should().BeTrue();
203+
}
204+
205+
[Fact]
206+
public void ContainsGeneralGuidanceLabel_ReturnsFalse_WhenNotPresent()
207+
{
208+
// Arrange
209+
var input = "Just a regular answer.";
210+
211+
// Act
212+
var result = ResponseTextSanitizer.ContainsGeneralGuidanceLabel(input);
213+
214+
// Assert
215+
result.Should().BeFalse();
216+
}
217+
218+
[Theory]
219+
[InlineData(null)]
220+
[InlineData("")]
221+
[InlineData(" ")]
222+
public void ContainsGroundedLabel_ReturnsFalse_ForNullOrEmptyInput(string? input)
223+
{
224+
// Act
225+
var result = ResponseTextSanitizer.ContainsGroundedLabel(input!);
226+
227+
// Assert
228+
result.Should().BeFalse();
229+
}
230+
231+
[Theory]
232+
[InlineData(null)]
233+
[InlineData("")]
234+
[InlineData(" ")]
235+
public void ContainsGeneralGuidanceLabel_ReturnsFalse_ForNullOrEmptyInput(string? input)
236+
{
237+
// Act
238+
var result = ResponseTextSanitizer.ContainsGeneralGuidanceLabel(input!);
239+
240+
// Assert
241+
result.Should().BeFalse();
242+
}
243+
}

src/backend/HealthPlanChat.Infrastructure.AgentFramework/AgentFrameworkChatAgent.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.RegularExpressions;
12
using Azure.AI.Agents.Persistent;
23
using Azure.AI.Projects;
34
using Azure.Identity;
@@ -151,15 +152,18 @@ await agentsClient.Messages.CreateMessageAsync(
151152
// Extract text content and citations
152153
var (responseText, references) = ExtractResponseAndCitations(assistantMessage);
153154

154-
// Determine answer type from response text
155+
// Determine answer type from response text (before sanitization)
155156
var answerType = DetermineAnswerType(responseText, references);
156157

158+
// Sanitize response text: strip answer type labels and citation markers
159+
var sanitizedText = ResponseTextSanitizer.Sanitize(responseText);
160+
157161
_logger.LogInformation(
158162
"Response generated. AnswerType: {AnswerType}, ReferenceCount: {ReferenceCount}",
159163
answerType,
160164
references.Count);
161165

162-
return new ChatAgentResponse(responseText, answerType, references);
166+
return new ChatAgentResponse(sanitizedText, answerType, references);
163167
}
164168
finally
165169
{
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace HealthPlanChat.Infrastructure.AgentFramework;
4+
5+
/// <summary>
6+
/// Sanitizes agent response text by removing answer type labels and citation markers.
7+
/// The AnswerType and References are already extracted separately, so these markers
8+
/// should not appear in the final user-visible text.
9+
/// </summary>
10+
public static partial class ResponseTextSanitizer
11+
{
12+
// Patterns for answer type labels (with optional markdown formatting)
13+
// Matches: **[GROUNDED]**, *[GROUNDED]*, [GROUNDED], **[GENERAL GUIDANCE]**, etc.
14+
[GeneratedRegex(@"\*{0,2}\[GROUNDED\]\*{0,2}", RegexOptions.IgnoreCase)]
15+
private static partial Regex GroundedLabelPattern();
16+
17+
[GeneratedRegex(@"\*{0,2}\[GENERAL GUIDANCE\]\*{0,2}", RegexOptions.IgnoreCase)]
18+
private static partial Regex GeneralGuidanceLabelPattern();
19+
20+
// Pattern for citation markers like 【3:0†source】, 【1:2†source】, etc.
21+
// Unicode brackets: 【 (U+3010) and 】 (U+3011)
22+
[GeneratedRegex(@"【\d+:\d+†[^】]*】")]
23+
private static partial Regex CitationMarkerPattern();
24+
25+
// Pattern for alternative citation formats like [doc_0], [doc_1], etc.
26+
[GeneratedRegex(@"\[doc_\d+\]")]
27+
private static partial Regex DocCitationPattern();
28+
29+
// Pattern for numbered citation markers like [1], [2], [3] at the end of sentences
30+
// Only match standalone citation numbers, not markdown links
31+
[GeneratedRegex(@"(?<!\])\[\d+\](?!\()")]
32+
private static partial Regex NumberedCitationPattern();
33+
34+
/// <summary>
35+
/// Sanitizes the response text by removing answer type labels and citation markers.
36+
/// </summary>
37+
/// <param name="responseText">The raw response text from the agent.</param>
38+
/// <returns>Sanitized text suitable for display to users.</returns>
39+
public static string Sanitize(string responseText)
40+
{
41+
if (string.IsNullOrEmpty(responseText))
42+
{
43+
return responseText;
44+
}
45+
46+
var result = responseText;
47+
48+
// Remove answer type labels
49+
result = GroundedLabelPattern().Replace(result, string.Empty);
50+
result = GeneralGuidanceLabelPattern().Replace(result, string.Empty);
51+
52+
// Remove citation markers (with optional preceding space)
53+
result = Regex.Replace(result, @"\s*【\d+:\d+†[^】]*】", string.Empty);
54+
result = Regex.Replace(result, @"\s*\[doc_\d+\]", string.Empty);
55+
result = Regex.Replace(result, @"\s*(?<!\])\[\d+\](?!\()", string.Empty);
56+
57+
// Clean up any resulting double spaces or leading/trailing whitespace
58+
result = Regex.Replace(result, @"[ \t]{2,}", " ");
59+
result = Regex.Replace(result, @"^\s+", string.Empty, RegexOptions.Multiline);
60+
result = result.Trim();
61+
62+
return result;
63+
}
64+
65+
/// <summary>
66+
/// Checks if the response text contains a grounded label.
67+
/// </summary>
68+
public static bool ContainsGroundedLabel(string responseText)
69+
{
70+
return !string.IsNullOrWhiteSpace(responseText) &&
71+
GroundedLabelPattern().IsMatch(responseText);
72+
}
73+
74+
/// <summary>
75+
/// Checks if the response text contains a general guidance label.
76+
/// </summary>
77+
public static bool ContainsGeneralGuidanceLabel(string responseText)
78+
{
79+
return !string.IsNullOrWhiteSpace(responseText) &&
80+
GeneralGuidanceLabelPattern().IsMatch(responseText);
81+
}
82+
}

0 commit comments

Comments
 (0)