Skip to content

Commit 234cdec

Browse files
committed
Add CustomQuestionAnsweringRecognizer
1 parent e20c0ff commit 234cdec

5 files changed

Lines changed: 438 additions & 3 deletions

File tree

bots/employee-finder/src/SSW.SophieBot.Components/Components/CommonComponent.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
using Microsoft.Bot.Builder;
1+
using Microsoft.Bot.Builder;
22
using Microsoft.Extensions.Configuration;
33
using Microsoft.Extensions.DependencyInjection;
44
using SSW.SophieBot.Components.Actions;
5+
using SSW.SophieBot.Components.Recognizers;
56
using SSW.SophieBot.Components.Services;
67

78
namespace SSW.SophieBot.Components.Components
@@ -21,6 +22,9 @@ public override void ConfigureServices(IServiceCollection services, IConfigurati
2122
services.AddDeclarativeType<TimeDifferenceAction>(TimeDifferenceAction.Kind);
2223
services.AddDeclarativeType<EnrichDatatimeAction>(EnrichDatatimeAction.Kind);
2324
services.AddDeclarativeType<UsageByUserAction>(UsageByUserAction.Kind);
25+
26+
// Register Custom Question Answering recognizer
27+
services.AddDeclarativeType<CustomQuestionAnsweringRecognizer>(CustomQuestionAnsweringRecognizer.Kind);
2428
}
2529
}
2630
}
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) SSW. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.ComponentModel;
7+
using System.Globalization;
8+
using System.Linq;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using AdaptiveExpressions.Properties;
12+
using Azure;
13+
using Azure.AI.Language.QuestionAnswering;
14+
using Microsoft.Bot.Builder;
15+
using Microsoft.Bot.Builder.Dialogs;
16+
using Microsoft.Bot.Builder.Dialogs.Adaptive.Recognizers;
17+
using Microsoft.Bot.Schema;
18+
using Newtonsoft.Json;
19+
using Newtonsoft.Json.Linq;
20+
21+
namespace SSW.SophieBot.Components.Recognizers
22+
{
23+
/// <summary>
24+
/// IRecognizer implementation which uses Azure AI Language Question Answering to identify intents.
25+
/// This uses the official Azure.AI.Language.QuestionAnswering SDK.
26+
/// </summary>
27+
public class CustomQuestionAnsweringRecognizer : Recognizer
28+
{
29+
/// <summary>
30+
/// The declarative type for this recognizer.
31+
/// </summary>
32+
[JsonProperty("$kind")]
33+
public const string Kind = "SSW.CustomQuestionAnsweringRecognizer";
34+
35+
/// <summary>
36+
/// Key used when adding the intent to the <see cref="RecognizerResult"/> intents collection.
37+
/// </summary>
38+
public const string QnAMatchIntent = "QnAMatch";
39+
40+
private const string IntentPrefix = "intent=";
41+
42+
private readonly JsonSerializerSettings _settings = new JsonSerializerSettings { MaxDepth = null };
43+
44+
/// <summary>
45+
/// Initializes a new instance of the <see cref="CustomQuestionAnsweringRecognizer"/> class.
46+
/// </summary>
47+
public CustomQuestionAnsweringRecognizer()
48+
{
49+
}
50+
51+
/// <summary>
52+
/// Gets or sets the Project Name of your Custom Question Answering project.
53+
/// </summary>
54+
[JsonProperty("projectName")]
55+
public StringExpression ProjectName { get; set; }
56+
57+
/// <summary>
58+
/// Gets or sets the endpoint URL for your Language Service.
59+
/// </summary>
60+
[JsonProperty("hostname")]
61+
public StringExpression HostName { get; set; }
62+
63+
/// <summary>
64+
/// Gets or sets the API key for the Language Service.
65+
/// </summary>
66+
[JsonProperty("endpointKey")]
67+
public StringExpression EndpointKey { get; set; }
68+
69+
/// <summary>
70+
/// Gets or sets the deployment name (e.g., "production" or "test").
71+
/// </summary>
72+
[JsonProperty("deploymentName")]
73+
public StringExpression DeploymentName { get; set; } = "production";
74+
75+
/// <summary>
76+
/// Gets or sets the number of results you want.
77+
/// </summary>
78+
[DefaultValue(3)]
79+
[JsonProperty("top")]
80+
public IntExpression Top { get; set; } = 3;
81+
82+
/// <summary>
83+
/// Gets or sets the threshold score to filter results.
84+
/// </summary>
85+
[DefaultValue(0.3)]
86+
[JsonProperty("threshold")]
87+
public NumberExpression Threshold { get; set; } = 0.3;
88+
89+
/// <summary>
90+
/// Gets or sets whether to include the dialog name metadata for QnA context.
91+
/// </summary>
92+
[DefaultValue(true)]
93+
[JsonProperty("includeDialogNameInMetadata")]
94+
public BoolExpression IncludeDialogNameInMetadata { get; set; } = true;
95+
96+
/// <summary>
97+
/// Gets or sets the flag to determine if personal information should be logged in telemetry.
98+
/// </summary>
99+
[JsonProperty("logPersonalInformation")]
100+
public BoolExpression LogPersonalInformation { get; set; } = "=settings.runtimeSettings.telemetry.logPersonalInformation";
101+
102+
/// <summary>
103+
/// Gets or sets whether to enable precise answer.
104+
/// </summary>
105+
[JsonProperty("enablePreciseAnswer")]
106+
public BoolExpression EnablePreciseAnswer { get; set; } = true;
107+
108+
/// <summary>
109+
/// Return results of the call to Custom Question Answering.
110+
/// </summary>
111+
public override async Task<RecognizerResult> RecognizeAsync(DialogContext dialogContext, Activity activity, CancellationToken cancellationToken, Dictionary<string, string> telemetryProperties = null, Dictionary<string, double> telemetryMetrics = null)
112+
{
113+
var recognizerResult = new RecognizerResult
114+
{
115+
Text = activity.Text,
116+
Intents = new Dictionary<string, IntentScore>(),
117+
};
118+
119+
if (string.IsNullOrEmpty(activity.Text))
120+
{
121+
recognizerResult.Intents.Add("None", new IntentScore());
122+
return recognizerResult;
123+
}
124+
125+
try
126+
{
127+
// Get configuration values
128+
var (epKey, error1) = EndpointKey.TryGetValue(dialogContext.State);
129+
var (hostname, error2) = HostName.TryGetValue(dialogContext.State);
130+
var (projectName, error3) = ProjectName.TryGetValue(dialogContext.State);
131+
var (deploymentName, error4) = DeploymentName.TryGetValue(dialogContext.State);
132+
var (top, _) = Top.TryGetValue(dialogContext.State);
133+
var (threshold, _) = Threshold.TryGetValue(dialogContext.State);
134+
var (enablePreciseAnswer, _) = EnablePreciseAnswer.TryGetValue(dialogContext.State);
135+
136+
if (string.IsNullOrEmpty(epKey))
137+
{
138+
throw new InvalidOperationException($"Unable to get a value for {nameof(EndpointKey)} from state. {error1}");
139+
}
140+
if (string.IsNullOrEmpty(hostname))
141+
{
142+
throw new InvalidOperationException($"Unable to get a value for {nameof(HostName)} from state. {error2}");
143+
}
144+
if (string.IsNullOrEmpty(projectName))
145+
{
146+
throw new InvalidOperationException($"Unable to get a value for {nameof(ProjectName)} from state. {error3}");
147+
}
148+
149+
// Default deployment name if not specified
150+
if (string.IsNullOrEmpty(deploymentName))
151+
{
152+
deploymentName = "production";
153+
}
154+
155+
// Create the Question Answering client using Azure SDK
156+
var endpoint = new Uri(hostname);
157+
var credential = new AzureKeyCredential(epKey);
158+
var client = new QuestionAnsweringClient(endpoint, credential);
159+
160+
// Create the project reference
161+
var project = new QuestionAnsweringProject(projectName, deploymentName);
162+
163+
// Configure options
164+
var options = new AnswersOptions
165+
{
166+
Size = top,
167+
ConfidenceThreshold = threshold,
168+
IncludeUnstructuredSources = true,
169+
ShortAnswerOptions = enablePreciseAnswer ? new ShortAnswerOptions { ConfidenceThreshold = threshold } : null
170+
};
171+
172+
// Add metadata filters if configured
173+
if (IncludeDialogNameInMetadata.GetValue(dialogContext.State))
174+
{
175+
options.Filters = new QueryFilters();
176+
options.Filters.MetadataFilter = new MetadataFilter();
177+
options.Filters.MetadataFilter.LogicalOperation = LogicalOperationKind.And;
178+
// Note: Metadata would be added here if needed
179+
}
180+
181+
// Call the Question Answering service
182+
Response<AnswersResult> response = await client.GetAnswersAsync(activity.Text, project, options, cancellationToken).ConfigureAwait(false);
183+
184+
if (response.Value.Answers != null && response.Value.Answers.Any())
185+
{
186+
// Filter by threshold
187+
var filteredAnswers = response.Value.Answers
188+
.Where(a => a.Confidence >= threshold)
189+
.OrderByDescending(a => a.Confidence)
190+
.ToList();
191+
192+
if (filteredAnswers.Any())
193+
{
194+
var topAnswer = filteredAnswers.First();
195+
196+
// Check if the answer starts with "intent=" prefix
197+
if (topAnswer.Answer.Trim().ToUpperInvariant().StartsWith(IntentPrefix.ToUpperInvariant(), StringComparison.Ordinal))
198+
{
199+
recognizerResult.Intents.Add(
200+
topAnswer.Answer.Trim().Substring(IntentPrefix.Length).Trim(),
201+
new IntentScore { Score = topAnswer.Confidence ?? 0 });
202+
}
203+
else
204+
{
205+
recognizerResult.Intents.Add(QnAMatchIntent, new IntentScore { Score = topAnswer.Confidence ?? 0 });
206+
}
207+
208+
// Add answer to entities
209+
var answerArray = new JArray();
210+
211+
// Use short answer if available and enabled, otherwise use full answer
212+
var answerText = enablePreciseAnswer && topAnswer.ShortAnswer != null && !string.IsNullOrEmpty(topAnswer.ShortAnswer.Text)
213+
? topAnswer.ShortAnswer.Text
214+
: topAnswer.Answer;
215+
216+
answerArray.Add(answerText);
217+
ObjectPath.SetPathValue(recognizerResult, "entities.answer", answerArray);
218+
219+
// Add instance data
220+
var instance = new JArray();
221+
var data = new JObject
222+
{
223+
["answer"] = topAnswer.Answer,
224+
["shortAnswer"] = topAnswer.ShortAnswer?.Text,
225+
["confidence"] = topAnswer.Confidence,
226+
["source"] = topAnswer.Source,
227+
["qnaId"] = topAnswer.QnaId,
228+
["startIndex"] = 0,
229+
["endIndex"] = activity.Text.Length
230+
};
231+
instance.Add(data);
232+
ObjectPath.SetPathValue(recognizerResult, "entities.$instance.answer", instance);
233+
234+
// Add all answers to properties
235+
var answersArray = filteredAnswers.Select(a => new
236+
{
237+
answer = a.Answer,
238+
shortAnswer = a.ShortAnswer?.Text,
239+
confidence = a.Confidence,
240+
source = a.Source,
241+
qnaId = a.QnaId,
242+
questions = a.Questions?.ToArray()
243+
}).ToArray();
244+
245+
recognizerResult.Properties["answers"] = JArray.FromObject(answersArray);
246+
}
247+
else
248+
{
249+
recognizerResult.Intents.Add("None", new IntentScore { Score = 1.0f });
250+
}
251+
}
252+
else
253+
{
254+
recognizerResult.Intents.Add("None", new IntentScore { Score = 1.0f });
255+
}
256+
257+
TrackRecognizerResult(dialogContext, "CustomQuestionAnsweringRecognizerResult", FillRecognizerResultTelemetryProperties(recognizerResult, telemetryProperties, dialogContext), telemetryMetrics);
258+
}
259+
catch (RequestFailedException ex)
260+
{
261+
// Log the error and return None intent
262+
System.Diagnostics.Debug.WriteLine($"Question Answering request failed: {ex.Message}");
263+
recognizerResult.Intents.Add("None", new IntentScore { Score = 1.0f });
264+
recognizerResult.Properties["error"] = ex.Message;
265+
}
266+
267+
return recognizerResult;
268+
}
269+
270+
/// <summary>
271+
/// Fill telemetry properties.
272+
/// </summary>
273+
protected override Dictionary<string, string> FillRecognizerResultTelemetryProperties(RecognizerResult recognizerResult, Dictionary<string, string> telemetryProperties, DialogContext dialogContext = null)
274+
{
275+
var properties = new Dictionary<string, string>
276+
{
277+
{ "TopIntent", recognizerResult.Intents.Any() ? recognizerResult.Intents.First().Key : null },
278+
{ "TopIntentScore", recognizerResult.Intents.Any() ? recognizerResult.Intents.First().Value?.Score?.ToString("N1", CultureInfo.InvariantCulture) : null },
279+
{ "Intents", recognizerResult.Intents.Any() ? JsonConvert.SerializeObject(recognizerResult.Intents, _settings) : null },
280+
{ "Entities", recognizerResult.Entities?.ToString() },
281+
{ "AdditionalProperties", recognizerResult.Properties.Any() ? JsonConvert.SerializeObject(recognizerResult.Properties, _settings) : null },
282+
};
283+
284+
if (dialogContext != null)
285+
{
286+
var (logPersonalInfo, _) = LogPersonalInformation.TryGetValue(dialogContext.State);
287+
if (logPersonalInfo && !string.IsNullOrEmpty(recognizerResult.Text))
288+
{
289+
properties.Add("Text", recognizerResult.Text);
290+
properties.Add("AlteredText", recognizerResult.AlteredText);
291+
}
292+
}
293+
294+
if (telemetryProperties != null)
295+
{
296+
return telemetryProperties.Concat(properties)
297+
.GroupBy(kv => kv.Key)
298+
.ToDictionary(g => g.Key, g => g.First().Value);
299+
}
300+
301+
return properties;
302+
}
303+
}
304+
}

0 commit comments

Comments
 (0)