-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSpeechMaticsService.cs
More file actions
683 lines (551 loc) · 35.6 KB
/
SpeechMaticsService.cs
File metadata and controls
683 lines (551 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
using System.Text.Json;
using System.Text.RegularExpressions;
using AutoMapper;
using Google.Cloud.Translation.V2;
using Serilog;
using SmartTalk.Core.Ioc;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenAI.Chat;
using Smarties.Messages.DTO.OpenAi;
using Smarties.Messages.Enums.OpenAi;
using Smarties.Messages.Requests.Ask;
using SmartTalk.Core.Constants;
using SmartTalk.Core.Domain.AISpeechAssistant;
using SmartTalk.Core.Domain.PhoneOrder;
using SmartTalk.Core.Domain.System;
using SmartTalk.Core.Services.AiSpeechAssistant;
using SmartTalk.Core.Services.Ffmpeg;
using SmartTalk.Core.Services.Http;
using SmartTalk.Core.Services.Http.Clients;
using SmartTalk.Core.Services.Jobs;
using SmartTalk.Core.Services.PhoneOrder;
using SmartTalk.Core.Services.Sale;
using SmartTalk.Core.Settings.OpenAi;
using SmartTalk.Core.Settings.PhoneOrder;
using SmartTalk.Core.Settings.Twilio;
using SmartTalk.Messages.Dto.SpeechMatics;
using SmartTalk.Messages.Enums.PhoneOrder;
using SmartTalk.Messages.Commands.PhoneOrder;
using SmartTalk.Messages.Dto.Agent;
using SmartTalk.Messages.Dto.AiSpeechAssistant;
using SmartTalk.Messages.Dto.Sales;
using SmartTalk.Messages.Dto.PhoneOrder;
using SmartTalk.Messages.Enums.Agent;
using SmartTalk.Messages.Enums.STT;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Exception = System.Exception;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace SmartTalk.Core.Services.SpeechMatics;
public interface ISpeechMaticsService : IScopedDependency
{
Task HandleTranscriptionCallbackAsync(HandleTranscriptionCallbackCommand command, CancellationToken cancellationToken);
}
public class SpeechMaticsService : ISpeechMaticsService
{
private readonly IMapper _mapper;
private readonly ISalesClient _salesClient;
private readonly IWeChatClient _weChatClient;
private readonly IFfmpegService _ffmpegService;
private readonly OpenAiSettings _openAiSettings;
private readonly TwilioSettings _twilioSettings;
private readonly TranslationClient _translationClient;
private readonly ISmartiesClient _smartiesClient;
private readonly PhoneOrderSetting _phoneOrderSetting;
private readonly IPhoneOrderService _phoneOrderService;
private readonly ISalesDataProvider _salesDataProvider;
private readonly IPhoneOrderDataProvider _phoneOrderDataProvider;
private readonly ISmartTalkHttpClientFactory _smartTalkHttpClientFactory;
private readonly ISmartTalkBackgroundJobClient _smartTalkBackgroundJobClient;
private readonly IAiSpeechAssistantDataProvider _aiSpeechAssistantDataProvider;
public SpeechMaticsService(
IMapper mapper,
ISalesClient salesClient,
IWeChatClient weChatClient,
IFfmpegService ffmpegService,
OpenAiSettings openAiSettings,
TwilioSettings twilioSettings,
TranslationClient translationClient,
ISmartiesClient smartiesClient,
PhoneOrderSetting phoneOrderSetting,
IPhoneOrderService phoneOrderService,
ISalesDataProvider salesDataProvider,
IPhoneOrderDataProvider phoneOrderDataProvider,
ISmartTalkHttpClientFactory smartTalkHttpClientFactory,
ISmartTalkBackgroundJobClient smartTalkBackgroundJobClient,
IAiSpeechAssistantDataProvider aiSpeechAssistantDataProvider)
{
_mapper = mapper;
_salesClient = salesClient;
_weChatClient = weChatClient;
_ffmpegService = ffmpegService;
_openAiSettings = openAiSettings;
_twilioSettings = twilioSettings;
_translationClient = translationClient;
_smartiesClient = smartiesClient;
_phoneOrderSetting = phoneOrderSetting;
_phoneOrderService = phoneOrderService;
_salesDataProvider = salesDataProvider;
_phoneOrderDataProvider = phoneOrderDataProvider;
_smartTalkHttpClientFactory = smartTalkHttpClientFactory;
_smartTalkBackgroundJobClient = smartTalkBackgroundJobClient;
_aiSpeechAssistantDataProvider = aiSpeechAssistantDataProvider;
}
public async Task HandleTranscriptionCallbackAsync(HandleTranscriptionCallbackCommand command, CancellationToken cancellationToken)
{
if (command.Transcription == null || command.Transcription.Job == null || command.Transcription.Job.Id.IsNullOrEmpty()) return;
var record = await _phoneOrderDataProvider.GetPhoneOrderRecordByTranscriptionJobIdAsync(command.Transcription.Job.Id, cancellationToken).ConfigureAwait(false);
Log.Information("Get Phone order record : {@record}", record);
if (record == null) return;
Log.Information("Transcription results : {@results}", command.Transcription.Results);
try
{
record.Status = PhoneOrderRecordStatus.Transcription;
await _phoneOrderDataProvider.UpdatePhoneOrderRecordsAsync(record, true, cancellationToken).ConfigureAwait(false);
var speakInfos = StructureDiarizationResults(command.Transcription.Results);
var audioContent = await _smartTalkHttpClientFactory.GetAsync<byte[]>(record.Url, cancellationToken).ConfigureAwait(false);
await _phoneOrderService.ExtractPhoneOrderRecordAiMenuAsync(speakInfos, record, audioContent, cancellationToken).ConfigureAwait(false);
await SummarizeConversationContentAsync(record, audioContent, cancellationToken).ConfigureAwait(false);
await _phoneOrderDataProvider.UpdatePhoneOrderRecordsAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
_smartTalkBackgroundJobClient.Enqueue<IPhoneOrderProcessJobService>(x => x.CalculateRecordingDurationAsync(record, null, cancellationToken), HangfireConstants.InternalHostingFfmpeg);
}
catch (Exception e)
{
record.Status = PhoneOrderRecordStatus.Exception;
await _phoneOrderDataProvider.UpdatePhoneOrderRecordsAsync(record, true, cancellationToken).ConfigureAwait(false);
Log.Warning("Handle transcription callback failed: {@Exception}", e);
}
}
private async Task SummarizeConversationContentAsync(PhoneOrderRecord record, byte[] audioContent, CancellationToken cancellationToken)
{
var (aiSpeechAssistant, agent) = await _aiSpeechAssistantDataProvider.GetAgentAndAiSpeechAssistantAsync(record.AgentId, record.AssistantId, cancellationToken).ConfigureAwait(false);
Log.Information("Get Assistant: {@Assistant} and Agent: {@Agent} by agent id {agentId}", aiSpeechAssistant, agent, record.AgentId);
var callFrom = string.Empty;
var callTo = string.Empty;
TwilioClient.Init(_twilioSettings.AccountSid, _twilioSettings.AuthToken);
try
{
await RetryAsync(async () =>
{
var call = await CallResource.FetchAsync(record.SessionId);
callFrom = call?.From;
callTo = call?.To;
Log.Information("Fetched incoming phone number from Twilio: {callFrom}", callFrom);
}, maxRetryCount: 3, delaySeconds: 3, cancellationToken);
}
catch (Exception e)
{
Log.Warning("Fetched incoming phone number from Twilio failed: {Message}", e.Message);
}
var pstTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"));
var currentTime = pstTime.ToString("yyyy-MM-dd HH:mm:ss");
var messages = await ConfigureRecordAnalyzePromptAsync(agent, aiSpeechAssistant, callFrom ?? "", currentTime, audioContent, cancellationToken);
ChatClient client = new("gpt-4o-audio-preview", _openAiSettings.ApiKey);
ChatCompletionOptions options = new() { ResponseModalities = ChatResponseModalities.Text };
ChatCompletion completion = await client.CompleteChatAsync(messages, options, cancellationToken);
Log.Information("sales record analyze report:" + completion.Content.FirstOrDefault()?.Text);
record.Status = PhoneOrderRecordStatus.Sent;
record.TranscriptionText = completion.Content.FirstOrDefault()?.Text ?? "";
var checkCustomerFriendly = await CheckCustomerFriendlyAsync(record.TranscriptionText, cancellationToken).ConfigureAwait(false);
record.IsCustomerFriendly = checkCustomerFriendly.IsCustomerFriendly;
record.IsHumanAnswered = checkCustomerFriendly.IsHumanAnswered;
var detection = await _translationClient.DetectLanguageAsync(record.TranscriptionText, cancellationToken).ConfigureAwait(false);
await MultiScenarioCustomProcessingAsync(agent, aiSpeechAssistant, record, cancellationToken).ConfigureAwait(false);
if (agent.SourceSystem == AgentSourceSystem.Smarties)
await CallBackSmartiesRecordAsync(agent, record, cancellationToken).ConfigureAwait(false);
var reports = new List<PhoneOrderRecordReport>();
reports.Add(new PhoneOrderRecordReport
{
RecordId = record.Id,
Report = record.TranscriptionText,
Language = SelectReportLanguageEnum(detection.Language),
IsOrigin = SelectReportLanguageEnum(detection.Language) == record.Language,
CreatedDate = DateTimeOffset.Now,
});
var targetLanguage = SelectReportLanguageEnum(detection.Language) == TranscriptionLanguage.Chinese ? "en" : "zh";
var reportLanguage = SelectReportLanguageEnum(detection.Language) == TranscriptionLanguage.Chinese ? TranscriptionLanguage.English : TranscriptionLanguage.Chinese;
var translatedText = await _translationClient.TranslateTextAsync(record.TranscriptionText, targetLanguage, cancellationToken: cancellationToken).ConfigureAwait(false);
reports.Add(new PhoneOrderRecordReport
{
RecordId = record.Id,
Report = translatedText.TranslatedText,
Language = reportLanguage,
IsOrigin = reportLanguage == record.Language,
CreatedDate = DateTimeOffset.Now,
});
await _phoneOrderDataProvider.AddPhoneOrderRecordReportsAsync(reports, true, cancellationToken).ConfigureAwait(false);
await CallBackSmartiesRecordAsync(agent, record, cancellationToken).ConfigureAwait(false);
var message = agent.WechatRobotMessage?.Replace("#{assistant_name}", aiSpeechAssistant?.Name ?? "").Replace("#{agent_id}", agent.Id.ToString()).Replace("#{record_id}", record.Id.ToString()).Replace("#{assistant_file_url}", record.Url);
message = await SwitchKeyMessageByGetUserProfileAsync(record, callFrom, aiSpeechAssistant, agent, message, cancellationToken).ConfigureAwait(false);
await SendWorkWechatMessageByRobotKeyAsync(message, record, audioContent, agent, aiSpeechAssistant, cancellationToken).ConfigureAwait(false);
}
private async Task<string> SwitchKeyMessageByGetUserProfileAsync(PhoneOrderRecord record, string callFrom, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, Agent agent, string message, CancellationToken cancellationToken)
{
if (callFrom != null && aiSpeechAssistant?.Id != null && !string.IsNullOrEmpty(message))
{
var userProfile = await _aiSpeechAssistantDataProvider.GetAiSpeechAssistantUserProfileAsync(aiSpeechAssistant.Id, callFrom, cancellationToken).ConfigureAwait(false);
var salesName = userProfile?.ProfileJson != null ? JObject.Parse(userProfile.ProfileJson).GetValue("correspond_sales")?.ToString() : string.Empty;
var salesDisplayName = !string.IsNullOrEmpty(salesName) ? $"{salesName}" : "";
message = message.Replace("#{sales_name}", salesDisplayName);
}
return message;
}
private async Task SendWorkWechatMessageByRobotKeyAsync(string message, PhoneOrderRecord record, byte[] audioContent, Agent agent, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(agent.WechatRobotKey) && !string.IsNullOrEmpty(message))
{
if (agent.IsWecomMessageOrder && aiSpeechAssistant != null)
{
var messageNumber = await SendAgentMessageRecordAsync(agent, record.Id, aiSpeechAssistant.GroupKey, cancellationToken);
message = $"【第{messageNumber}條】\n" + message;
}
if (agent.IsSendAnalysisReportToWechat && !string.IsNullOrEmpty(record.TranscriptionText))
{
message += "\n\n" + record.TranscriptionText;
}
await _phoneOrderService.SendWorkWeChatRobotNotifyAsync(audioContent, agent.WechatRobotKey, message, Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
}
}
private async Task CallBackSmartiesRecordAsync(Agent agent, PhoneOrderRecord record, CancellationToken cancellationToken = default)
{
if (agent.Type == AgentType.AiKid)
{
var aiKid = await _aiSpeechAssistantDataProvider.GetAiKidAsync(agentId: agent.Id, cancellationToken: cancellationToken).ConfigureAwait(false);
Log.Information("Get ai kid: {@Kid} by agentId: {AgentId}", aiKid, agent.Id);
if (aiKid == null)throw new Exception($"Could not found ai kid by agentId: {agent.Id}");
await _smartiesClient.CallBackSmartiesAiKidRecordAsync(new AiKidCallBackRequestDto
{
Url = record.Url,
Uuid = aiKid.KidUuid,
SessionId = record.SessionId
}, cancellationToken).ConfigureAwait(false);
}
else
await _smartiesClient.CallBackSmartiesAiSpeechAssistantRecordAsync(new AiSpeechAssistantCallBackRequestDto { CallSid = record.SessionId, RecordUrl = record.Url, RecordAnalyzeReport = record.TranscriptionText }, cancellationToken).ConfigureAwait(false);
}
private async Task<int> SendAgentMessageRecordAsync(Agent agent, int recordId, int groupKey, CancellationToken cancellationToken)
{
var timezone = !string.IsNullOrWhiteSpace(agent.Timezone) ? TimeZoneInfo.FindSystemTimeZoneById(agent.Timezone) : TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");
var nowDate = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timezone);
var utcDate = TimeZoneInfo.ConvertTimeToUtc(nowDate.Date, timezone);
var existingCount = await _aiSpeechAssistantDataProvider.GetMessageCountByAgentAndDateAsync(groupKey, utcDate, cancellationToken).ConfigureAwait(false);
var messageNumber = existingCount + 1;
var newRecord = new AgentMessageRecord
{
AgentId = agent.Id,
GroupKey = groupKey,
RecordId = recordId,
MessageNumber = messageNumber
};
await _aiSpeechAssistantDataProvider.AddAgentMessageRecordAsync(newRecord, cancellationToken).ConfigureAwait(false);
return messageNumber;
}
private List<SpeechMaticsSpeakInfoDto> StructureDiarizationResults(List<SpeechMaticsResultDto> results)
{
string currentSpeaker = null;
PhoneOrderRole? currentRole = null;
var startTime = 0.0;
var endTime = 0.0;
var speakInfos = new List<SpeechMaticsSpeakInfoDto>();
foreach (var result in results.Where(result => !result.Alternatives.IsNullOrEmpty()))
{
if (currentSpeaker == null)
{
currentSpeaker = result.Alternatives[0].Speaker;
currentRole = PhoneOrderRole.Restaurant;
startTime = result.StartTime;
endTime = result.EndTime;
continue;
}
if (result.Alternatives[0].Speaker.Equals(currentSpeaker))
{
endTime = result.EndTime;
}
else
{
speakInfos.Add(new SpeechMaticsSpeakInfoDto { EndTime = endTime, StartTime = startTime, Speaker = currentSpeaker, Role = currentRole.Value });
currentSpeaker = result.Alternatives[0].Speaker;
currentRole = currentRole == PhoneOrderRole.Restaurant ? PhoneOrderRole.Client : PhoneOrderRole.Restaurant;
startTime = result.StartTime;
endTime = result.EndTime;
}
}
speakInfos.Add(new SpeechMaticsSpeakInfoDto { EndTime = endTime, StartTime = startTime, Speaker = currentSpeaker });
Log.Information("Structure diarization results : {@speakInfos}", speakInfos);
return speakInfos;
}
private TranscriptionLanguage SelectReportLanguageEnum(string language)
{
if (language.StartsWith("zh", StringComparison.OrdinalIgnoreCase))
return TranscriptionLanguage.Chinese;
return TranscriptionLanguage.English;
}
private async Task RetryAsync(
Func<Task> action,
int maxRetryCount,
int delaySeconds,
CancellationToken cancellationToken)
{
for (int attempt = 1; attempt <= maxRetryCount + 1; attempt++)
{
try
{
await action();
return;
}
catch (Exception ex) when (attempt <= maxRetryCount)
{
Log.Warning(ex, "重試第 {Attempt} 次失敗,稍後再試…", attempt);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
}
}
}
private async Task<List<ChatMessage>> ConfigureRecordAnalyzePromptAsync(Agent agent, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, string callFrom, string currentTime, byte[] audioContent, CancellationToken cancellationToken)
{
var soldToIds = !string.IsNullOrEmpty(aiSpeechAssistant.Name) ? aiSpeechAssistant.Name.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList() : new List<string>();
var customerItemsCacheList = await _salesDataProvider.GetCustomerItemsCacheBySoldToIdsAsync(soldToIds, cancellationToken);
var customerItemsString = string.Join(Environment.NewLine, soldToIds.Select(id => customerItemsCacheList.FirstOrDefault(c => c.Filter == id)?.CacheValue ?? ""));
var audioData = BinaryData.FromBytes(audioContent);
List<ChatMessage> messages =
[
new SystemChatMessage( (string.IsNullOrEmpty(aiSpeechAssistant?.CustomRecordAnalyzePrompt)
? "你是一名電話錄音的分析員,通過聽取錄音內容和語氣情緒作出精確分析,冩出一份分析報告。\n\n分析報告的格式:交談主題:xxx\n\n 來電號碼:#{call_from}\n\n 內容摘要:xxx \n\n 客人情感與情緒: xxx \n\n 待辦事件: \n1.xxx\n2.xxx \n\n 客人下單內容(如果沒有則忽略):1. 牛肉(1箱)\n2. 雞腿肉(1箱)"
: aiSpeechAssistant.CustomRecordAnalyzePrompt).Replace("#{call_from}", callFrom ?? "").Replace("#{current_time}", currentTime ?? "").Replace("#{customer_items}", customerItemsString ?? "")),
new UserChatMessage(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Wav)),
new UserChatMessage("幫我根據錄音生成分析報告:")
];
return messages;
}
private async Task MultiScenarioCustomProcessingAsync(Agent agent, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, PhoneOrderRecord record, CancellationToken cancellationToken)
{
switch (agent.Type)
{
case AgentType.Sales:
if (!string.IsNullOrEmpty(record.TranscriptionText))
{
if (!aiSpeechAssistant.IsAllowOrderPush)
{
Log.Information("Assistant.Name={AssistantName} 的 is_allow_order_push=false,跳过生成草稿单", aiSpeechAssistant.Name);
return;
}
await HandleSalesScenarioAsync(agent, aiSpeechAssistant, record, cancellationToken).ConfigureAwait(false);
}
break;
}
}
private async Task HandleSalesScenarioAsync(Agent agent, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, PhoneOrderRecord record, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(record.TranscriptionText)) return;
var soldToIds = new List<string>();
if (!string.IsNullOrEmpty(aiSpeechAssistant.Name))
soldToIds = aiSpeechAssistant.Name.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
var historyItems = await GetCustomerHistoryItemsBySoldToIdAsync(soldToIds, cancellationToken).ConfigureAwait(false);
var extractedOrders = await ExtractAndMatchOrderItemsFromReportAsync(record.TranscriptionText, historyItems, cancellationToken).ConfigureAwait(false);
if (!extractedOrders.Any()) return;
var pacificZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");
var pacificNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, pacificZone);
foreach (var storeOrder in extractedOrders)
{
var soldToId = await ResolveSoldToIdAsync(storeOrder, aiSpeechAssistant, soldToIds, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(soldToId))
{
Log.Warning("未能获取店铺 SoldToId, StoreName={StoreName}, StoreNumber={StoreNumber}", storeOrder.StoreName, storeOrder.StoreNumber);
}
foreach (var item in storeOrder.Orders)
{
item.MaterialNumber = MatchMaterialNumber(item.Name, item.MaterialNumber, item.Unit, historyItems);
}
var draftOrder = CreateDraftOrder(storeOrder, soldToId, aiSpeechAssistant, pacificZone, pacificNow);
Log.Information("DraftOrder for Store {StoreName}/{StoreNumber}: {@DraftOrder}", storeOrder.StoreName, storeOrder.StoreNumber, draftOrder);
var response = await _salesClient.GenerateAiOrdersAsync(draftOrder, cancellationToken).ConfigureAwait(false);
Log.Information("Generate Ai Order response for Store {StoreName}/{StoreNumber}: {@response}", storeOrder.StoreName, storeOrder.StoreNumber, response);
if (response?.Data != null && response.Data.OrderId != Guid.Empty)
{
await UpdateRecordOrderIdAsync(record, response.Data.OrderId, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<List<ExtractedOrderDto>> ExtractAndMatchOrderItemsFromReportAsync(string reportText, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems, CancellationToken cancellationToken)
{
var client = new ChatClient("gpt-4.1", _openAiSettings.ApiKey);
var materialListText = string.Join("\n", historyItems.Select(x => $"{x.MaterialDesc} ({x.Material})【{x.invoiceDate}】"));
var systemPrompt =
"你是一名訂單分析助手。請從下面的客戶分析報告文字中提取所有下單的物料名稱、數量、單位,並且用歷史物料列表盡力匹配每個物料的materialNumber。" +
"如果報告中提到了預約送貨時間,請提取送貨時間(格式yyyy-MM-dd)。" +
"如果客戶提到了分店名,請提取 StoreName;如果提到第幾家店,請提取 StoreNumber。\n" +
"請嚴格傳回一個 JSON 對象,頂層字段為 \"stores\",每个店铺对象包含:StoreName(可空字符串), StoreNumber(可空字符串), DeliveryDate(可空字符串),orders(数组,元素包含 name, quantity, unit, materialNumber, deliveryDate)。\n" +
"範例:\n" +
"{\n \"stores\": [\n {\n \"StoreName\": \"HaiDiLao\",\n \"StoreNumber\": \"1\",\n \"DeliveryDate\": \"2025-08-20\",\n \"orders\": [\n {\n \"name\": \"雞胸肉\",\n \"quantity\": 1,\n \"unit\": \"箱\",\n \"materialNumber\": \"000000000010010253\"\n }\n ]\n }\n ]\n}" +
"歷史物料列表:\n" + materialListText + "\n\n" +
"每個物料的格式為「物料名稱(物料號碼)」,部分物料會包含日期\n 當有多個相似的物料名稱時,請根據以下規則選擇匹配的物料號碼:1. **優先選擇沒有日期的物料。**\n 2. 如果所有相似物料都有日期,請選擇日期**最新** 的那個物料。\n\n "+
"注意:\n1. 必須嚴格輸出 JSON,物件頂層字段必須是 \"stores\",不要有其他字段或額外說明。\n2. 提取的物料名稱需要為繁體中文。\n3. 如果没有提到店铺信息,但是有下单内容,则StoreName和StoreNumber可为空值,orders要正常提取。\n4. **如果客戶分析文本中沒有任何可識別的下單信息,請返回:{ \"stores\": [] }。不得臆造或猜測物料。** \n" +
"請務必完整提取報告中每一個提到的物料";
Log.Information("Sending prompt to GPT: {Prompt}", systemPrompt);
var messages = new List<ChatMessage>
{
new SystemChatMessage(systemPrompt),
new UserChatMessage("客戶分析報告文本:\n" + reportText + "\n\n")
};
var completion = await client.CompleteChatAsync(messages, new ChatCompletionOptions { ResponseModalities = ChatResponseModalities.Text, ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat() }, cancellationToken).ConfigureAwait(false);
var jsonResponse = completion.Value.Content.FirstOrDefault()?.Text ?? "";
Log.Information("AI JSON Response: {JsonResponse}", jsonResponse);
try
{
using var jsonDoc = JsonDocument.Parse(jsonResponse);
var storesArray = jsonDoc.RootElement.GetProperty("stores");
var results = new List<ExtractedOrderDto>();
foreach (var storeElement in storesArray.EnumerateArray())
{
var storeDto = new ExtractedOrderDto
{
StoreName = storeElement.TryGetProperty("StoreName", out var sn) ? sn.GetString() ?? "" : "",
StoreNumber = storeElement.TryGetProperty("StoreNumber", out var snum) ? snum.GetString() ?? "" : "",
DeliveryDate = storeElement.TryGetProperty("DeliveryDate", out var dd) && DateTime.TryParse(dd.GetString(), out var dt) ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(1)
};
if (storeElement.TryGetProperty("orders", out var ordersArray))
{
foreach (var orderItem in ordersArray.EnumerateArray())
{
var name = orderItem.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var qty = orderItem.TryGetProperty("quantity", out var q) && q.TryGetDecimal(out var dec) ? dec : 0;
var unit = orderItem.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
var materialNumber = orderItem.TryGetProperty("materialNumber", out var mn) ? mn.GetString() ?? "" : "";
materialNumber = MatchMaterialNumber(name, materialNumber, unit, historyItems);
storeDto.Orders.Add(new ExtractedOrderItemDto
{
Name = name,
Quantity = (int)qty,
MaterialNumber = materialNumber,
Unit = unit
});
}
}
results.Add(storeDto);
}
return results;
}
catch (Exception ex)
{
Log.Warning("解析GPT返回JSON失败: {Message}", ex.Message);
return new List<ExtractedOrderDto>();
}
}
private async Task<List<(string Material, string MaterialDesc, DateTime? InvoiceDate)>> GetCustomerHistoryItemsBySoldToIdAsync(List<string> soldToIds, CancellationToken cancellationToken)
{
List<(string Material, string MaterialDesc, DateTime? InvoiceDate)> historyItems = new List<(string, string, DateTime?)>();
var askInfoResponse = await _salesClient.GetAskInfoDetailListByCustomerAsync(new GetAskInfoDetailListByCustomerRequestDto { CustomerNumbers = soldToIds }, cancellationToken).ConfigureAwait(false);
var orderHistoryResponse = await _salesClient.GetOrderHistoryByCustomerAsync(new GetOrderHistoryByCustomerRequestDto { CustomerNumber = soldToIds.FirstOrDefault() }, cancellationToken).ConfigureAwait(false);
if (askInfoResponse?.Data != null && askInfoResponse.Data.Any())
historyItems.AddRange(askInfoResponse.Data.Where(x => !string.IsNullOrWhiteSpace(x.Material)).Select(x => (x.Material, x.MaterialDesc, (DateTime?)null)));
if (orderHistoryResponse?.Data != null && orderHistoryResponse.Data.Any())
historyItems.AddRange(orderHistoryResponse?.Data.Where(x => !string.IsNullOrWhiteSpace(x.MaterialNumber)).Select(x => (x.MaterialNumber, x.MaterialDescription, x.LastInvoiceDate)) ?? new List<(string, string, DateTime?)>());
return historyItems;
}
private string MatchMaterialNumber(string itemName, string baseNumber, string unit, List<(string Material, string MaterialDesc, DateTime? invoiceDate)> historyItems)
{
var candidates = historyItems.Where(x => x.MaterialDesc != null && x.MaterialDesc.Contains(itemName, StringComparison.OrdinalIgnoreCase)).Select(x => x.Material).ToList();
Log.Information("Candidate material code list: {@Candidates}", candidates);
if (!candidates.Any()) return string.IsNullOrEmpty(baseNumber) ? "" : baseNumber;
if (candidates.Count == 1) return candidates.First();
var isCase = !string.IsNullOrWhiteSpace(unit) && (unit.Contains("case", StringComparison.OrdinalIgnoreCase) || unit.Contains("箱"));
if (isCase)
{
var noPcList = candidates.Where(x => !x.Contains("PC", StringComparison.OrdinalIgnoreCase)).ToList();
if (noPcList.Any())
return noPcList.First();
return candidates.First();
}
var pcList = candidates.Where(x => x.Contains("PC", StringComparison.OrdinalIgnoreCase)).ToList();
if (pcList.Any())
return pcList.First();
return candidates.First();
}
private async Task<string> ResolveSoldToIdAsync(ExtractedOrderDto storeOrder, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, List<string> soldToIds, CancellationToken cancellationToken)
{
if (soldToIds.Count == 1)
return soldToIds[0];
if (!string.IsNullOrEmpty(storeOrder.StoreName))
{
var requestDto = new GetCustomerNumbersByNameRequestDto { CustomerName = storeOrder.StoreName };
var customerNumber = await _salesClient.GetCustomerNumbersByNameAsync(requestDto, cancellationToken).ConfigureAwait(false);
return customerNumber?.Data?.FirstOrDefault()?.CustomerNumber ?? string.Empty;
}
if (!string.IsNullOrEmpty(storeOrder.StoreNumber) && soldToIds.Any() && int.TryParse(storeOrder.StoreNumber, out var storeIndex) && storeIndex > 0 && storeIndex <= soldToIds.Count)
{
return soldToIds[storeIndex - 1];
}
if (soldToIds.Count > 1) return string.Empty;
return aiSpeechAssistant.Name;
}
private GenerateAiOrdersRequestDto CreateDraftOrder(ExtractedOrderDto storeOrder, string soldToId, Domain.AISpeechAssistant.AiSpeechAssistant aiSpeechAssistant, TimeZoneInfo pacificZone, DateTime pacificNow)
{
var pacificDeliveryDate = storeOrder.DeliveryDate != default ? TimeZoneInfo.ConvertTimeFromUtc(storeOrder.DeliveryDate, pacificZone) : pacificNow.AddDays(1);
var assistantNameWithComma = aiSpeechAssistant.Name?.Replace('/', ',') ?? string.Empty;
return new GenerateAiOrdersRequestDto
{
AiModel = "Smartalk",
AiOrderInfoDto = new AiOrderInfoDto
{
SoldToId = soldToId,
AiAssistantId = aiSpeechAssistant.Id,
SoldToIds = string.IsNullOrEmpty(soldToId) ? assistantNameWithComma : soldToId,
DocumentDate = pacificNow.Date,
DeliveryDate = pacificDeliveryDate.Date,
AiOrderItemDtoList = storeOrder.Orders.Select(i => new AiOrderItemDto
{
MaterialNumber = i.MaterialNumber,
AiMaterialDesc = i.Name,
MaterialQuantity = i.Quantity,
AiUnit = i.Unit
}).ToList()
}
};
}
private async Task UpdateRecordOrderIdAsync(PhoneOrderRecord record, Guid orderId, CancellationToken cancellationToken)
{
var orderIds = string.IsNullOrEmpty(record.OrderId) ? new List<Guid>() : JsonSerializer.Deserialize<List<Guid>>(record.OrderId)!;
orderIds.Add(orderId);
record.OrderId = JsonSerializer.Serialize(orderIds);
await _phoneOrderDataProvider.UpdatePhoneOrderRecordsAsync(record, true, cancellationToken).ConfigureAwait(false);
}
private async Task<(bool IsHumanAnswered, bool IsCustomerFriendly)> CheckCustomerFriendlyAsync(string transcriptionText, CancellationToken cancellationToken)
{
var completionResult = await _smartiesClient.PerformQueryAsync(new AskGptRequest
{
Messages = new List<CompletionsRequestMessageDto>
{
new()
{
Role = "system",
Content = new CompletionsStringContent(
"你需要帮我从电话录音报告中判断两个维度:" +
"1. 是否真人接听(IsHumanAnswered):" +
" - 如果客户有自然对话、提问、回应、表达等语气,说明是真人接听,返回 true。" +
" - 如果是语音信箱、系统提示、无人应答,返回 false。" +
"2. 客人态度是否友好(IsCustomerFriendly):" +
" - 如果语气平和、客气、积极配合,返回 true。" +
" - 如果语气恶劣、冷淡、负面或不耐烦,返回 false。" +
"输出格式务必是 JSON:" +
"{\"IsHumanAnswered\": true, \"IsCustomerFriendly\": true}" +
"\n\n样例:\n" +
"input: 通話主題:客戶查詢價格。\n內容摘要:客戶開場問候並詢問價格,語氣平和,最後表示感謝。\noutput: {\"IsHumanAnswered\": true, \"IsCustomerFriendly\": true}\n" +
"input: 通話主題:外呼無人接聽。\n內容摘要:撥號後自動語音提示‘您撥打的電話暫時無法接通’。\noutput: {\"IsHumanAnswered\": false, \"IsCustomerFriendly\": false}\n"
)
},
new()
{
Role = "user",
Content = new CompletionsStringContent($"input: {transcriptionText}, output:")
}
},
Model = OpenAiModel.Gpt4o,
ResponseFormat = new() { Type = "json_object" }
}, cancellationToken).ConfigureAwait(false);
var response = completionResult.Data.Response?.Trim();
var result = JsonConvert.DeserializeObject<PhoneOrderCustomerAttitudeAnalysis>(response);
if (result == null) throw new Exception($"无法反序列化模型返回结果: {response}");
return (result.IsHumanAnswered, result.IsCustomerFriendly);
}
}