refactor(workservice): 迁移AI批改逻辑到定时任务,移除消费端评分逻辑

1. 新增JournalTaskAiScoreJob定时任务,实现周期性扫描处理期刊答题记录AI批改
2. 调整appsettings.json配置,新增定时任务配置与相关参数
3. 重构JournalTaskReceiveConsumer,移除原有的AI评分相关代码,仅保留答题记录保存逻辑
4. 优化任务查询条件,支持分组任务查询
This commit is contained in:
glz
2026-07-08 18:00:44 +08:00
parent de9a0c2978
commit 4bb03aa0df
3 changed files with 973 additions and 765 deletions

View File

@ -1,11 +1,8 @@
using QYZH.InteractiveMagazine.Infrastructure.OSS;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using SqlSugar;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
@ -15,21 +12,10 @@ namespace QYZH.InteractiveMagazine.WorkService.Consumers;
public class JournalTaskReceiveConsumer(
ILogger<JournalTaskReceiveConsumer> logger,
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
OssService ossService) : IQueueConsumer
IServiceScopeFactory scopeFactory) : IQueueConsumer
{
private const int DefaultAiScoreMaxRetryCount = 3;
private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
private const int DefaultAiMaxConcurrency = 1;
private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
private const float DefaultCompletionThreshold = 80;
private const float DefaultCommunityScoreThreshold = 90;
private const string InvalidAnswerResult = "作答内容不符合要求";
private const string AiProcessingMessage = "AI批阅中请稍后";
private static readonly object AiSemaphoreLock = new();
private static SemaphoreSlim? aiSemaphore;
private static int aiSemaphoreLimit;
public string Exchange => "ex.journal";
@ -63,7 +49,7 @@ public class JournalTaskReceiveConsumer(
var taskMap = tasks.ToDictionary(t => t.Id);
var groupIds = tasks.Select(t => t.GroupId > 0 ? t.GroupId : t.Id).Distinct().ToList();
var groupTasks = await client.Queryable<JournalPageTask>()
.Where(t => groupIds.Contains(t.GroupId) && !t.IsDeleted)
.Where(t => (groupIds.Contains(t.GroupId) || groupIds.Contains(t.Id)) && !t.IsDeleted)
.ToListAsync(cancellationToken);
var persistedTaskMap = groupTasks
.GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id)
@ -71,13 +57,6 @@ public class JournalTaskReceiveConsumer(
g => g.Key,
g => g.FirstOrDefault(t => t.Id == g.Key)
?? g.OrderBy(t => t.Id).First());
var groupTaskIds = groupTasks.Select(t => t.Id).Distinct().ToList();
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
.Where(a => groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
.ToListAsync(cancellationToken);
var referenceAnswerMap = referenceAnswers
.GroupBy(a => a.JournalPageTaskId)
.ToDictionary(g => g.Key, g => g.ToList());
var pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList();
var pages = await client.Queryable<JournalPage>()
@ -85,15 +64,7 @@ public class JournalTaskReceiveConsumer(
.ToListAsync(cancellationToken);
var pageMap = pages.ToDictionary(p => p.Id);
var existingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
.Where(a => a.UserId == data.UserId && groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
.ToListAsync(cancellationToken);
var existingAnswerMap = existingAnswers
.GroupBy(a => a.JournalPageTaskId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First());
var questionContexts = new List<JournalQuestionContext>();
var answerContexts = new List<JournalAnswerContext>();
foreach (var question in data.Questions)
{
if (!taskMap.TryGetValue(question.Id, out var task))
@ -102,61 +73,27 @@ public class JournalTaskReceiveConsumer(
continue;
}
if (!task.NeedAiProcess)
{
logger.LogInformation("期刊任务配置为人工批改跳过AI评分TaskId: {TaskId}, UserId: {UserId}", task.Id, data.UserId);
continue;
}
pageMap.TryGetValue(task.JournalPageId, out var taskPage);
questionContexts.Add(new JournalQuestionContext(question, task, taskPage));
}
var answerContexts = new List<JournalAnswerContext>();
foreach (var scoreUnit in BuildScoreUnits(questionContexts))
{
var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap);
if (persistedContext == null)
{
logger.LogWarning("期刊任务评分单元缺少可入库任务UserId: {UserId}, GroupId: {GroupId}",
data.UserId, scoreUnit.GroupId);
logger.LogWarning("期刊任务上传单元缺少可入库任务UserId: {UserId}, GroupId: {GroupId}", data.UserId, scoreUnit.GroupId);
continue;
}
if (IsCompletedSameAnswer(scoreUnit, persistedContext.Task, data, existingAnswerMap))
{
logger.LogInformation("期刊任务作答已完成且内容未变化跳过AI评分UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}",
data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id)));
continue;
}
try
{
var scoreResult = await ScoreQuestionAsync(scoreUnit, referenceAnswerMap, cancellationToken);
var normalizedResult = NormalizeScoreResult(scoreResult, persistedContext.Task);
answerContexts.Add(new JournalAnswerContext(
BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, normalizedResult, GetCompletionThreshold()),
persistedContext.Question,
persistedContext.Task,
normalizedResult));
LogSkippedGroupTasks(scoreUnit, persistedContext.Task);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "期刊任务AI评分失败已标记为处理中UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}",
data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id)));
var failureResult = BuildFailureScoreResult();
answerContexts.Add(new JournalAnswerContext(
BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, failureResult, GetCompletionThreshold()),
persistedContext.Question,
persistedContext.Task,
failureResult));
LogSkippedGroupTasks(scoreUnit, persistedContext.Task);
}
var processingResult = BuildFailureScoreResult();
answerContexts.Add(new JournalAnswerContext(
BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, processingResult, GetCompletionThreshold()),
persistedContext.Question,
persistedContext.Task,
processingResult));
LogSkippedGroupTasks(scoreUnit, persistedContext.Task);
}
if (answerContexts.Count == 0)
@ -178,24 +115,19 @@ public class JournalTaskReceiveConsumer(
if (existing == null)
{
await client.Insertable(answer).ExecuteCommandAsync(cancellationToken);
}
else
{
await client.Insertable(BuildAnswerSnapshot(existing)).ExecuteCommandAsync(cancellationToken);
answer.Id = existing.Id;
answer.CreatedBy = existing.CreatedBy;
answer.CreatedAt = existing.CreatedAt;
answer.UpdatedBy = answer.UserId.ToString();
answer.UpdatedAt = DateTime.Now;
await client.Updateable(answer)
.IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt })
.Where(a => a.Id == existing.Id)
.ExecuteCommandAsync(cancellationToken);
continue;
}
await InsertCommunityMessageIfNeededAsync(client, context, cancellationToken);
answer.Id = existing.Id;
answer.CreatedBy = existing.CreatedBy;
answer.CreatedAt = existing.CreatedAt;
answer.UpdatedBy = answer.UserId.ToString();
answer.UpdatedAt = DateTime.Now;
await client.Updateable(answer)
.IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt })
.Where(a => a.Id == existing.Id)
.ExecuteCommandAsync(cancellationToken);
}
client.Ado.CommitTran();
@ -206,10 +138,9 @@ public class JournalTaskReceiveConsumer(
throw;
}
logger.LogInformation("期刊任务答题记录保存完成UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}",
logger.LogInformation("期刊任务上传数据保存完成UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}",
data.UserId, data.JournalId, data.PageId, answerContexts.Count);
}
public Task OnErrorAsync(byte[] body, Exception exception)
{
var message = Encoding.UTF8.GetString(body);
@ -217,537 +148,6 @@ public class JournalTaskReceiveConsumer(
return Task.CompletedTask;
}
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
JournalScoreUnit scoreUnit,
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap,
CancellationToken cancellationToken)
{
var apiKey = configuration["AiChat:ApiKey"];
var baseUrl = configuration["AiChat:BaseUrl"];
var model = configuration["AiChat:Model"];
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
var temperature = configuration.GetValue<double?>("AiChat:Temperature");
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model))
{
throw new InvalidOperationException("AI聊天服务配置不完整请检查 AiChat 配置节点");
}
var answerMapPrompt = BuildScorePrompt(scoreUnit, referenceAnswerMap);
logger.LogInformation(answerMapPrompt);
var content = new List<object>
{
new
{
type = "text",
text = answerMapPrompt
}
};
var totalAnswerImageCount = 0;
foreach (var context in scoreUnit.Questions)
{
var answerImages = await BuildAnswerImageContentsAsync(context.Question, cancellationToken);
totalAnswerImageCount += answerImages.Count;
content.Add(new
{
type = "text",
text = $"以下是学生作答图片,共 {answerImages.Count} 张。"
});
foreach (var answerImage in answerImages)
{
content.Add(new
{
type = "image_url",
image_url = new { url = answerImage.DataUrl }
});
}
}
if (totalAnswerImageCount == 0)
{
throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片");
}
var requestBody = new
{
model,
messages = new object[]
{
new
{
role = "system",
content = "你是专业的学生作答评分助手。必须只返回合法 JSON不要返回 Markdown、解释或代码块。"
},
new
{
role = "user",
content
}
},
max_tokens = maxTokens > 0 ? maxTokens : 2000,
temperature = temperature is >= 0 ? temperature.Value : 0.1,
stream = false,
response_format = new { type = "json_object" }
};
var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var semaphore = GetAiSemaphore();
await semaphore.WaitAsync(cancellationToken);
try
{
var scoreResult = await SendAiScoreRequestWithRetryAsync(
scoreUnit.GroupId,
$"{baseUrl.TrimEnd('/')}/chat/completions",
apiKey,
requestJson,
timeoutSeconds > 0 ? timeoutSeconds : 300,
cancellationToken);
scoreResult.Result = TrimResult(scoreResult.Result);
return scoreResult;
}
finally
{
semaphore.Release();
}
}
private static string BuildScorePrompt(
JournalScoreUnit scoreUnit,
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap)
{
var referenceAnswerTexts = scoreUnit.Questions
.SelectMany(q =>
{
referenceAnswerMap.TryGetValue(q.Task.Id, out var answers);
return answers ?? [];
})
.Select(a => a.Answer?.Trim())
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct()
.ToList();
var prompt = new StringBuilder();
prompt.AppendLine(scoreUnit.Questions.Count > 1
? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。"
: "这是单页题目作答,请根据学生作答图片评分。");
prompt.AppendLine("以题目 Prompt 和学生答案为准评分,若有参考答案请结合参考答案。");
if (referenceAnswerTexts.Count > 0)
{
prompt.AppendLine("参考答案文本:");
for (var i = 0; i < referenceAnswerTexts.Count; i++)
{
prompt.AppendLine($"{i + 1}. {referenceAnswerTexts[i]}");
}
}
prompt.AppendLine();
prompt.AppendLine("题目信息:");
foreach (var context in scoreUnit.Questions)
{
prompt.AppendLine($" 题目内容:{context.Task.Task}");
prompt.AppendLine($" 评分Prompt{context.Task.Prompt}");
prompt.AppendLine($" 成长值上限:{context.Task.GrowthPoint}");
prompt.AppendLine($" 积分上限:{context.Task.Points}");
prompt.AppendLine($" 理解力上限:{context.Task.Comprehension}");
prompt.AppendLine($" 判断力上限:{context.Task.Judgment}");
prompt.AppendLine($" 表达力上限:{context.Task.Expression}");
prompt.AppendLine($" 说服力上限:{context.Task.Persuasiveness}");
}
prompt.AppendLine();
prompt.AppendLine("评分要求:");
prompt.AppendLine("- 不得超过题目配置中的各项上限。");
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion不要猜测高分。");
prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。");
prompt.AppendLine("- Result 返回 50 字内中文评语。");
prompt.AppendLine("若作答内容与题目要求不符、答非所问、空白、仅抄题或无法形成有效答案Score/GrowthPoint/Points/Comprehension/Judgment/Expression/Persuasiveness 均返回 0。");
prompt.AppendLine("若作答内容不符合要求Result 必须且只能返回:作答内容不符合要求。不要补充原因、建议或其他文字。");
prompt.AppendLine();
prompt.AppendLine("只返回如下 JSON 字段:");
prompt.AppendLine("{");
prompt.AppendLine(" \"Score\": 0,");
prompt.AppendLine(" \"GrowthPoint\": 0,");
prompt.AppendLine(" \"Points\": 0,");
prompt.AppendLine(" \"Comprehension\": 0,");
prompt.AppendLine(" \"Judgment\": 0,");
prompt.AppendLine(" \"Expression\": 0,");
prompt.AppendLine(" \"Persuasiveness\": 0,");
prompt.AppendLine(" \"Completion\": 100,");
prompt.AppendLine(" \"Result\": \"50字内的中文评语\"");
prompt.AppendLine("}");
return prompt.ToString();
}
private async Task<List<AnswerImageContent>> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken)
{
var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? [];
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
foreach (var imageUrl in imageUrls)
{
cancellationToken.ThrowIfCancellationRequested();
await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken);
if (imageStream == null)
{
throw new InvalidOperationException($"答案图片读取失败TaskId: {question.Id}, Url: {imageUrl}");
}
using var memoryStream = new MemoryStream();
await imageStream.CopyToAsync(memoryStream, cancellationToken);
if (memoryStream.Length == 0)
{
throw new InvalidOperationException($"答案图片内容为空TaskId: {question.Id}, Url: {imageUrl}");
}
if (memoryStream.Length > maxImageBytes)
{
throw new InvalidOperationException($"答案图片超过大小限制TaskId: {question.Id}, Url: {imageUrl}, Size: {memoryStream.Length}");
}
var imageBytes = memoryStream.ToArray();
var mimeType = GetImageMimeType(imageUrl, imageBytes);
result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"));
}
return result;
}
private async Task<List<AnswerImageContent>> BuildReferenceAnswerImageContentsAsync(
long taskId,
List<JournalPageTaskAnswer> referenceAnswers,
CancellationToken cancellationToken)
{
var imageUrls = referenceAnswers
.Select(a => a.AnswerUrL)
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct()
.Select(url => url!)
.ToList();
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
foreach (var imageUrl in imageUrls)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken);
if (imageStream == null)
{
throw new InvalidOperationException($"参考答案图片读取失败TaskId: {taskId}, Url: {imageUrl}");
}
using var memoryStream = new MemoryStream();
await imageStream.CopyToAsync(memoryStream, cancellationToken);
if (memoryStream.Length == 0)
{
throw new InvalidOperationException($"参考答案图片内容为空TaskId: {taskId}, Url: {imageUrl}");
}
if (memoryStream.Length > maxImageBytes)
{
throw new InvalidOperationException($"参考答案图片超过大小限制TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}");
}
var imageBytes = memoryStream.ToArray();
var mimeType = GetImageMimeType(imageUrl, imageBytes);
result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "参考答案图片读取失败已跳过TaskId: {TaskId}, Url: {Url}", taskId, imageUrl);
}
}
return result;
}
private async Task<JournalAnswerScoreResult> SendAiScoreRequestWithRetryAsync(
long taskId,
string requestUrl,
string apiKey,
string requestJson,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var maxRetryCount = configuration.GetValue<int>("AiChat:ScoreMaxRetryCount");
if (maxRetryCount <= 0)
{
maxRetryCount = DefaultAiScoreMaxRetryCount;
}
var retryDelayMilliseconds = configuration.GetValue<int>("AiChat:ScoreRetryDelayMilliseconds");
if (retryDelayMilliseconds <= 0)
{
retryDelayMilliseconds = DefaultAiScoreRetryDelayMilliseconds;
}
Exception? lastException = null;
for (var attempt = 1; attempt <= maxRetryCount; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Content = new StringContent(requestJson, Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var resultJson = ExtractAssistantContent(responseContent);
return ParseScoreResult(resultJson);
}
logger.LogWarning("AI评分调用失败TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}",
taskId, attempt, maxRetryCount, response.StatusCode, responseContent);
if (!ShouldRetry(response.StatusCode))
{
throw new NonRetryAiScoreException($"AI评分调用失败{response.StatusCode},响应:{responseContent}");
}
if (attempt == maxRetryCount)
{
throw new InvalidOperationException($"AI评分调用失败{response.StatusCode},响应:{responseContent}");
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) when (attempt < maxRetryCount)
{
lastException = ex;
logger.LogWarning(ex, "AI评分调用超时准备重试TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
}
catch (OperationCanceledException ex)
{
throw new InvalidOperationException($"AI评分调用超时TaskId: {taskId}", ex);
}
catch (Exception ex) when (attempt < maxRetryCount && ex is not NonRetryAiScoreException)
{
lastException = ex;
logger.LogWarning(ex, "AI评分调用异常准备重试TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
}
var delay = TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt);
await Task.Delay(delay, cancellationToken);
}
throw new InvalidOperationException($"AI评分调用失败TaskId: {taskId}", lastException);
}
private async Task<Stream?> GetImageStreamAsync(string imageUrl, CancellationToken cancellationToken)
{
if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
var httpClient = httpClientFactory.CreateClient();
return await httpClient.GetStreamAsync(uri, cancellationToken);
}
return ossService.GetObjectStream(imageUrl);
}
private static bool ShouldRetry(System.Net.HttpStatusCode statusCode)
{
var status = (int)statusCode;
return status == 408 || status == 429 || status >= 500;
}
private static string GetImageMimeType(string imageUrl, byte[] imageBytes)
{
if (imageBytes.Length >= 4)
{
if (imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47)
{
return "image/png";
}
if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8)
{
return "image/jpeg";
}
if (imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46)
{
return "image/gif";
}
if (imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46)
{
return "image/webp";
}
}
var extension = Path.GetExtension(imageUrl).ToLowerInvariant();
return extension switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
private static string ExtractAssistantContent(string responseContent)
{
using var document = JsonDocument.Parse(responseContent);
var message = document.RootElement.GetProperty("choices")[0].GetProperty("message");
if (!message.TryGetProperty("content", out var contentElement))
{
throw new InvalidOperationException("AI评分响应缺少 content");
}
if (contentElement.ValueKind == JsonValueKind.String)
{
return contentElement.GetString() ?? string.Empty;
}
return contentElement.GetRawText();
}
private static JournalAnswerScoreResult ParseScoreResult(string resultJson)
{
var cleanedJson = CleanJsonContent(resultJson);
EnsureRequiredScoreFields(cleanedJson);
var result = JsonSerializer.Deserialize<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return result ?? throw new InvalidOperationException("AI评分结果解析失败");
}
private static string CleanJsonContent(string content)
{
var text = content.Trim();
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstLineEnd = text.IndexOf('\n');
if (firstLineEnd >= 0)
{
text = text[(firstLineEnd + 1)..];
}
var fenceIndex = text.LastIndexOf("```", StringComparison.Ordinal);
if (fenceIndex >= 0)
{
text = text[..fenceIndex];
}
}
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start >= 0 && end > start)
{
text = text[start..(end + 1)];
}
return text.Trim();
}
private static void EnsureRequiredScoreFields(string json)
{
using var document = JsonDocument.Parse(json);
var requiredFields = new[]
{
nameof(JournalAnswerScoreResult.Score),
nameof(JournalAnswerScoreResult.GrowthPoint),
nameof(JournalAnswerScoreResult.Points),
nameof(JournalAnswerScoreResult.Comprehension),
nameof(JournalAnswerScoreResult.Judgment),
nameof(JournalAnswerScoreResult.Expression),
nameof(JournalAnswerScoreResult.Persuasiveness),
nameof(JournalAnswerScoreResult.Completion),
nameof(JournalAnswerScoreResult.Result)
};
foreach (var field in requiredFields)
{
if (!document.RootElement.EnumerateObject().Any(p => string.Equals(p.Name, field, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"AI评分结果缺少字段{field}");
}
}
}
private static JournalAnswerScoreResult NormalizeScoreResult(JournalAnswerScoreResult scoreResult, JournalPageTask task)
{
var scoreMax = task.Comprehension + task.Judgment + task.Expression + task.Persuasiveness;
if (scoreMax <= 0)
{
scoreMax = 100;
}
return new JournalAnswerScoreResult
{
Score = Clamp(scoreResult.Score, 0, scoreMax),
GrowthPoint = (int)Clamp(scoreResult.GrowthPoint, 0, task.GrowthPoint),
Points = (int)Clamp(scoreResult.Points, 0, task.Points),
Comprehension = Clamp(scoreResult.Comprehension, 0, task.Comprehension),
Judgment = Clamp(scoreResult.Judgment, 0, task.Judgment),
Expression = Clamp(scoreResult.Expression, 0, task.Expression),
Persuasiveness = Clamp(scoreResult.Persuasiveness, 0, task.Persuasiveness),
Completion = Clamp(scoreResult.Completion, 0, 100),
Result = NormalizeResult(scoreResult.Result)
};
}
private static string NormalizeResult(string? result)
{
var trimmedResult = TrimResult(result);
if (trimmedResult.Contains("不符合要求", StringComparison.Ordinal) ||
trimmedResult.Contains("不符", StringComparison.Ordinal))
{
return InvalidAnswerResult;
}
return trimmedResult;
}
private static float Clamp(float value, float min, float max)
{
if (float.IsNaN(value) || float.IsInfinity(value))
{
return min;
}
if (max < min)
{
max = min;
}
return Math.Min(Math.Max(value, min), max);
}
private static JournalAnswerScoreResult BuildFailureScoreResult()
{
return new JournalAnswerScoreResult
@ -756,7 +156,6 @@ public class JournalTaskReceiveConsumer(
Result = AiProcessingMessage
};
}
private static List<JournalScoreUnit> BuildScoreUnits(List<JournalQuestionContext> contexts)
{
return contexts
@ -795,41 +194,6 @@ public class JournalTaskReceiveConsumer(
return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
}
private static bool IsCompletedSameAnswer(
JournalScoreUnit scoreUnit,
JournalPageTask persistedTask,
QuestionData data,
Dictionary<long, JournalPageTaskUserAnswer> existingAnswerMap)
{
return scoreUnit.Questions.Count > 0
&& existingAnswerMap.TryGetValue(persistedTask.Id, out var existing)
&& existing.Status == (int)UserAnswerStatusEnum.Complete
&& string.Equals(existing.PageAnswerUrl ?? string.Empty, data.PageAnswerUrl ?? string.Empty, StringComparison.Ordinal)
&& string.Equals(existing.AnswerUrl ?? string.Empty, SerializeAnswerUrls(scoreUnit), StringComparison.Ordinal)
&& existing.AnswerStartTime == GetAnswerStartTime(scoreUnit)
&& existing.AnswerEndTime == GetAnswerEndTime(scoreUnit);
}
private SemaphoreSlim GetAiSemaphore()
{
var maxConcurrency = configuration.GetValue<int>("AiChat:MaxConcurrency");
if (maxConcurrency <= 0)
{
maxConcurrency = DefaultAiMaxConcurrency;
}
lock (AiSemaphoreLock)
{
if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency)
{
aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
aiSemaphoreLimit = maxConcurrency;
}
return aiSemaphore;
}
}
private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask)
{
var skippedTaskIds = scoreUnit.Questions
@ -954,111 +318,6 @@ public class JournalTaskReceiveConsumer(
};
}
private static JournalPageTaskUserAnswerSnapshot BuildAnswerSnapshot(JournalPageTaskUserAnswer answer)
{
var now = DateTime.Now;
return new JournalPageTaskUserAnswerSnapshot
{
JournalPageTaskUserAnswerId = answer.Id,
JournalId = answer.JournalId,
JournalPageId = answer.JournalPageId,
JournalPageTaskId = answer.JournalPageTaskId,
JournalPageTaskGroupId = answer.JournalPageTaskGroupId,
UserId = answer.UserId,
Result = answer.Result,
Points = answer.Points,
GrowthPoints = answer.GrowthPoints,
Score = answer.Score,
Comprehension = answer.Comprehension,
Judgment = answer.Judgment,
Expression = answer.Expression,
Persuasiveness = answer.Persuasiveness,
QuestionAnswerUrl = answer.QuestionAnswerUrl,
AnswerUrl = answer.AnswerUrl,
PageAnswerUrl = answer.PageAnswerUrl,
Revision = answer.Revision,
AnswerStartTime = answer.AnswerStartTime,
AnswerEndTime = answer.AnswerEndTime,
AnswerSeconds = answer.AnswerSeconds,
ImageRecognition = answer.ImageRecognition,
JournalPageNum = answer.JournalPageNum,
Modify = answer.Modify,
LastTag = answer.LastTag,
DotPageNum = answer.DotPageNum,
PageResultUrl = answer.PageResultUrl,
Type = answer.Type,
DotPageNo = answer.DotPageNo,
PageAnswerDotUrl = answer.PageAnswerDotUrl,
BreakCount = answer.BreakCount,
BreakTimes = answer.BreakTimes,
AssignmentStatus = answer.AssignmentStatus,
Status = answer.Status,
CreatedBy = answer.UpdatedBy ?? answer.CreatedBy ?? string.Empty,
CreatedAt = now,
UpdatedBy = answer.UpdatedBy ?? string.Empty,
UpdatedAt = now
};
}
private async Task InsertCommunityMessageIfNeededAsync(
ISqlSugarClient client,
JournalAnswerContext context,
CancellationToken cancellationToken)
{
var scoreThreshold = configuration.GetValue<float>("AiChat:CommunityScoreThreshold");
if (scoreThreshold <= 0)
{
scoreThreshold = DefaultCommunityScoreThreshold;
}
if (context.Answer.Status != (int)UserAnswerStatusEnum.Complete || context.ScoreResult.Score < scoreThreshold)
{
return;
}
var exists = await client.Queryable<CommunityMessage>()
.Where(m => m.JournalTaskAnswerId == context.Answer.Id && !m.IsDeleted)
.AnyAsync(cancellationToken);
if (exists)
{
return;
}
var userJournal = await client.Queryable<UserJournal>()
.Where(uj => uj.UserId == context.Answer.UserId && uj.JournalId == context.Answer.JournalId && !uj.IsDeleted)
.FirstAsync(cancellationToken);
if (userJournal == null)
{
logger.LogWarning("高分答案未找到用户期刊关系跳过社区消息写入UserId: {UserId}, JournalId: {JournalId}, AnswerId: {AnswerId}",
context.Answer.UserId, context.Answer.JournalId, context.Answer.Id);
return;
}
var now = DateTime.Now;
var communityMessage = new CommunityMessage
{
JournalId = context.Answer.JournalId,
UserId = context.Answer.UserId,
UserJournalId = userJournal.Id,
JournalTaskId = context.Answer.JournalPageTaskId,
JournalTaskAnswerId = context.Answer.Id,
Content = context.ScoreResult.Result,
ImageUrl = context.Question.AnswerUrl.FirstOrDefault() ?? string.Empty,
SortOrder = 0,
IsActive = true,
Type = MessageTypeEnum.Message,
LikeCount = 0,
IsFeatured = 0,
Status = 1,
CreatedBy = context.Answer.UserId.ToString(),
CreatedAt = now,
UpdatedBy = context.Answer.UserId.ToString(),
UpdatedAt = now
};
await client.Insertable(communityMessage).ExecuteCommandAsync(cancellationToken);
}
private float GetCompletionThreshold()
{
var threshold = configuration.GetValue<float>("AiChat:CompletionThreshold");