refactor(workservice): 迁移AI批改逻辑到定时任务,移除消费端评分逻辑
1. 新增JournalTaskAiScoreJob定时任务,实现周期性扫描处理期刊答题记录AI批改 2. 调整appsettings.json配置,新增定时任务配置与相关参数 3. 重构JournalTaskReceiveConsumer,移除原有的AI评分相关代码,仅保留答题记录保存逻辑 4. 优化任务查询条件,支持分组任务查询
This commit is contained in:
@ -1,11 +1,8 @@
|
|||||||
using QYZH.InteractiveMagazine.Infrastructure.OSS;
|
|
||||||
using QYZH.InteractiveMagazine.Models.Entity;
|
using QYZH.InteractiveMagazine.Models.Entity;
|
||||||
using QYZH.InteractiveMagazine.Models.Enum;
|
using QYZH.InteractiveMagazine.Models.Enum;
|
||||||
using SqlSugar;
|
using SqlSugar;
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
|
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
|
||||||
|
|
||||||
@ -15,21 +12,10 @@ namespace QYZH.InteractiveMagazine.WorkService.Consumers;
|
|||||||
public class JournalTaskReceiveConsumer(
|
public class JournalTaskReceiveConsumer(
|
||||||
ILogger<JournalTaskReceiveConsumer> logger,
|
ILogger<JournalTaskReceiveConsumer> logger,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory) : IQueueConsumer
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
OssService ossService) : 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 DefaultCompletionThreshold = 80;
|
||||||
private const float DefaultCommunityScoreThreshold = 90;
|
|
||||||
private const string InvalidAnswerResult = "作答内容不符合要求";
|
|
||||||
private const string AiProcessingMessage = "AI批阅中,请稍后";
|
private const string AiProcessingMessage = "AI批阅中,请稍后";
|
||||||
private static readonly object AiSemaphoreLock = new();
|
|
||||||
private static SemaphoreSlim? aiSemaphore;
|
|
||||||
private static int aiSemaphoreLimit;
|
|
||||||
|
|
||||||
public string Exchange => "ex.journal";
|
public string Exchange => "ex.journal";
|
||||||
|
|
||||||
@ -63,7 +49,7 @@ public class JournalTaskReceiveConsumer(
|
|||||||
var taskMap = tasks.ToDictionary(t => t.Id);
|
var taskMap = tasks.ToDictionary(t => t.Id);
|
||||||
var groupIds = tasks.Select(t => t.GroupId > 0 ? t.GroupId : t.Id).Distinct().ToList();
|
var groupIds = tasks.Select(t => t.GroupId > 0 ? t.GroupId : t.Id).Distinct().ToList();
|
||||||
var groupTasks = await client.Queryable<JournalPageTask>()
|
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);
|
.ToListAsync(cancellationToken);
|
||||||
var persistedTaskMap = groupTasks
|
var persistedTaskMap = groupTasks
|
||||||
.GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id)
|
.GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id)
|
||||||
@ -71,13 +57,6 @@ public class JournalTaskReceiveConsumer(
|
|||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => g.FirstOrDefault(t => t.Id == g.Key)
|
g => g.FirstOrDefault(t => t.Id == g.Key)
|
||||||
?? g.OrderBy(t => t.Id).First());
|
?? 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 pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList();
|
||||||
var pages = await client.Queryable<JournalPage>()
|
var pages = await client.Queryable<JournalPage>()
|
||||||
@ -85,15 +64,7 @@ public class JournalTaskReceiveConsumer(
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var pageMap = pages.ToDictionary(p => p.Id);
|
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 questionContexts = new List<JournalQuestionContext>();
|
||||||
var answerContexts = new List<JournalAnswerContext>();
|
|
||||||
foreach (var question in data.Questions)
|
foreach (var question in data.Questions)
|
||||||
{
|
{
|
||||||
if (!taskMap.TryGetValue(question.Id, out var task))
|
if (!taskMap.TryGetValue(question.Id, out var task))
|
||||||
@ -102,61 +73,27 @@ public class JournalTaskReceiveConsumer(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task.NeedAiProcess)
|
|
||||||
{
|
|
||||||
logger.LogInformation("期刊任务配置为人工批改,跳过AI评分,TaskId: {TaskId}, UserId: {UserId}", task.Id, data.UserId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
pageMap.TryGetValue(task.JournalPageId, out var taskPage);
|
pageMap.TryGetValue(task.JournalPageId, out var taskPage);
|
||||||
questionContexts.Add(new JournalQuestionContext(question, task, taskPage));
|
questionContexts.Add(new JournalQuestionContext(question, task, taskPage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var answerContexts = new List<JournalAnswerContext>();
|
||||||
foreach (var scoreUnit in BuildScoreUnits(questionContexts))
|
foreach (var scoreUnit in BuildScoreUnits(questionContexts))
|
||||||
{
|
{
|
||||||
var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap);
|
var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap);
|
||||||
if (persistedContext == null)
|
if (persistedContext == null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("期刊任务评分单元缺少可入库任务,UserId: {UserId}, GroupId: {GroupId}",
|
logger.LogWarning("期刊任务上传单元缺少可入库任务,UserId: {UserId}, GroupId: {GroupId}", data.UserId, scoreUnit.GroupId);
|
||||||
data.UserId, scoreUnit.GroupId);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsCompletedSameAnswer(scoreUnit, persistedContext.Task, data, existingAnswerMap))
|
var processingResult = BuildFailureScoreResult();
|
||||||
{
|
answerContexts.Add(new JournalAnswerContext(
|
||||||
logger.LogInformation("期刊任务作答已完成且内容未变化,跳过AI评分,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}",
|
BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, processingResult, GetCompletionThreshold()),
|
||||||
data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id)));
|
persistedContext.Question,
|
||||||
continue;
|
persistedContext.Task,
|
||||||
}
|
processingResult));
|
||||||
|
LogSkippedGroupTasks(scoreUnit, persistedContext.Task);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answerContexts.Count == 0)
|
if (answerContexts.Count == 0)
|
||||||
@ -178,24 +115,19 @@ public class JournalTaskReceiveConsumer(
|
|||||||
if (existing == null)
|
if (existing == null)
|
||||||
{
|
{
|
||||||
await client.Insertable(answer).ExecuteCommandAsync(cancellationToken);
|
await client.Insertable(answer).ExecuteCommandAsync(cancellationToken);
|
||||||
}
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
client.Ado.CommitTran();
|
||||||
@ -206,10 +138,9 @@ public class JournalTaskReceiveConsumer(
|
|||||||
throw;
|
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);
|
data.UserId, data.JournalId, data.PageId, answerContexts.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task OnErrorAsync(byte[] body, Exception exception)
|
public Task OnErrorAsync(byte[] body, Exception exception)
|
||||||
{
|
{
|
||||||
var message = Encoding.UTF8.GetString(body);
|
var message = Encoding.UTF8.GetString(body);
|
||||||
@ -217,537 +148,6 @@ public class JournalTaskReceiveConsumer(
|
|||||||
return Task.CompletedTask;
|
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()
|
private static JournalAnswerScoreResult BuildFailureScoreResult()
|
||||||
{
|
{
|
||||||
return new JournalAnswerScoreResult
|
return new JournalAnswerScoreResult
|
||||||
@ -756,7 +156,6 @@ public class JournalTaskReceiveConsumer(
|
|||||||
Result = AiProcessingMessage
|
Result = AiProcessingMessage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<JournalScoreUnit> BuildScoreUnits(List<JournalQuestionContext> contexts)
|
private static List<JournalScoreUnit> BuildScoreUnits(List<JournalQuestionContext> contexts)
|
||||||
{
|
{
|
||||||
return contexts
|
return contexts
|
||||||
@ -795,41 +194,6 @@ public class JournalTaskReceiveConsumer(
|
|||||||
return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
|
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)
|
private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask)
|
||||||
{
|
{
|
||||||
var skippedTaskIds = scoreUnit.Questions
|
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()
|
private float GetCompletionThreshold()
|
||||||
{
|
{
|
||||||
var threshold = configuration.GetValue<float>("AiChat:CompletionThreshold");
|
var threshold = configuration.GetValue<float>("AiChat:CompletionThreshold");
|
||||||
|
|||||||
@ -0,0 +1,940 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using QYZH.InteractiveMagazine.Infrastructure.OSS;
|
||||||
|
using QYZH.InteractiveMagazine.Models.Entity;
|
||||||
|
using QYZH.InteractiveMagazine.Models.Enum;
|
||||||
|
using QYZH.InteractiveMagazine.WorkService.Consumers;
|
||||||
|
using SqlSugar;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace QYZH.InteractiveMagazine.WorkService.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 期刊任务AI批改定时任务。
|
||||||
|
/// </summary>
|
||||||
|
public class JournalTaskAiScoreJob(
|
||||||
|
ILogger<JournalTaskAiScoreJob> logger,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
OssService ossService)
|
||||||
|
{
|
||||||
|
private const int DefaultPendingAnswerMinutes = 30;
|
||||||
|
private const int DefaultPendingAnswerBatchSize = 100;
|
||||||
|
private const int DefaultAiScoreMaxRetryCount = 3;
|
||||||
|
private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
|
||||||
|
private const int DefaultAiMaxConcurrency = 1;
|
||||||
|
private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
|
||||||
|
private const string InvalidAnswerResult = "作答内容不符合要求";
|
||||||
|
private const string AiProcessingMessage = "AI批阅中,请稍后";
|
||||||
|
private static readonly object AiSemaphoreLock = new();
|
||||||
|
private static readonly object RunWindowLock = new();
|
||||||
|
private static SemaphoreSlim? aiSemaphore;
|
||||||
|
private static int aiSemaphoreLimit;
|
||||||
|
private static DateTime? lastRunAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行AI批改。
|
||||||
|
/// </summary>
|
||||||
|
[DisableConcurrentExecution(1800)]
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
ExecuteAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 异步执行AI批改。
|
||||||
|
/// </summary>
|
||||||
|
public async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
|
||||||
|
var windowEnd = DateTime.Now;
|
||||||
|
var windowStart = GetWindowStart(windowEnd);
|
||||||
|
var batchSize = GetPendingAnswerBatchSize();
|
||||||
|
|
||||||
|
var pendingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
|
||||||
|
.Where(a => !a.IsDeleted
|
||||||
|
&& a.Status == (int)UserAnswerStatusEnum.Processing
|
||||||
|
&& ((a.UpdatedAt != null && a.UpdatedAt > windowStart && a.UpdatedAt <= windowEnd)
|
||||||
|
|| (a.UpdatedAt == null && a.CreatedAt > windowStart && a.CreatedAt <= windowEnd)))
|
||||||
|
.OrderBy(a => a.UpdatedAt, OrderByType.Asc)
|
||||||
|
.OrderBy(a => a.CreatedAt, OrderByType.Asc)
|
||||||
|
.Take(batchSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (pendingAnswers.Count == 0)
|
||||||
|
{
|
||||||
|
SetLastRunAt(windowEnd);
|
||||||
|
logger.LogInformation("期刊AI批改任务没有待处理答案,WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", windowStart, windowEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("期刊AI批改任务开始,Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", pendingAnswers.Count, windowStart, windowEnd);
|
||||||
|
foreach (var pendingAnswer in pendingAnswers)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessPendingAnswerAsync(client, pendingAnswer.Id, windowStart, windowEnd, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "期刊AI批改答案失败,AnswerId: {AnswerId}", pendingAnswer.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLastRunAt(windowEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessPendingAnswerAsync(
|
||||||
|
ISqlSugarClient client,
|
||||||
|
long answerId,
|
||||||
|
DateTime windowStart,
|
||||||
|
DateTime windowEnd,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var answer = await client.Queryable<JournalPageTaskUserAnswer>()
|
||||||
|
.Where(a => a.Id == answerId && !a.IsDeleted)
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
var answerTime = answer == null ? default : GetPendingTime(answer);
|
||||||
|
if (answer == null
|
||||||
|
|| answer.Status != (int)UserAnswerStatusEnum.Processing
|
||||||
|
|| answerTime <= windowStart
|
||||||
|
|| answerTime > windowEnd)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = await client.Queryable<JournalPageTask>()
|
||||||
|
.Where(t => t.Id == answer.JournalPageTaskId && !t.IsDeleted)
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("期刊AI批改未找到任务,AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, answer.JournalPageTaskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.NeedAiProcess)
|
||||||
|
{
|
||||||
|
logger.LogInformation("期刊任务配置为人工批改,跳过AI批改,AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, task.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalUpdatedAt = answer.UpdatedAt;
|
||||||
|
var originalAnswerUrl = answer.AnswerUrl ?? string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scoreUnit = await BuildScoreUnitAsync(client, answer, task, cancellationToken);
|
||||||
|
var referenceAnswerMap = await BuildReferenceAnswerMapAsync(client, scoreUnit, cancellationToken);
|
||||||
|
var answerQuestion = BuildQuestionFromAnswer(answer);
|
||||||
|
var scoreResult = await ScoreQuestionAsync(scoreUnit, answerQuestion, referenceAnswerMap, cancellationToken);
|
||||||
|
var normalizedResult = NormalizeScoreResult(scoreResult, task);
|
||||||
|
await CompleteAnswerAsync(client, answer.Id, originalUpdatedAt, originalAnswerUrl, normalizedResult, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "期刊AI批改调用失败,保持处理中等待下次重试,AnswerId: {AnswerId}", answer.Id);
|
||||||
|
await client.Updateable<JournalPageTaskUserAnswer>()
|
||||||
|
.SetColumns(a => new JournalPageTaskUserAnswer
|
||||||
|
{
|
||||||
|
Result = AiProcessingMessage,
|
||||||
|
UpdatedBy = "AI",
|
||||||
|
UpdatedAt = DateTime.Now
|
||||||
|
})
|
||||||
|
.Where(a => a.Id == answer.Id && a.Status == (int)UserAnswerStatusEnum.Processing)
|
||||||
|
.ExecuteCommandAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteAnswerAsync(
|
||||||
|
ISqlSugarClient client,
|
||||||
|
long answerId,
|
||||||
|
DateTime? originalUpdatedAt,
|
||||||
|
string originalAnswerUrl,
|
||||||
|
JournalAnswerScoreResult scoreResult,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
client.Ado.BeginTran();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var latest = await client.Queryable<JournalPageTaskUserAnswer>()
|
||||||
|
.Where(a => a.Id == answerId && !a.IsDeleted)
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
if (latest == null
|
||||||
|
|| latest.Status != (int)UserAnswerStatusEnum.Processing
|
||||||
|
|| latest.UpdatedAt != originalUpdatedAt
|
||||||
|
|| !string.Equals(latest.AnswerUrl ?? string.Empty, originalAnswerUrl, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
client.Ado.CommitTran();
|
||||||
|
logger.LogInformation("期刊AI批改结果已过期,跳过写入,AnswerId: {AnswerId}", answerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.Insertable(BuildAnswerSnapshot(latest)).ExecuteCommandAsync(cancellationToken);
|
||||||
|
|
||||||
|
latest.Result = scoreResult.Result;
|
||||||
|
latest.Points = Math.Max(0, scoreResult.Points);
|
||||||
|
latest.GrowthPoints = Math.Max(0, scoreResult.GrowthPoint);
|
||||||
|
latest.Score = Math.Max(0, scoreResult.Score);
|
||||||
|
latest.Comprehension = Math.Max(0, scoreResult.Comprehension);
|
||||||
|
latest.Judgment = Math.Max(0, scoreResult.Judgment);
|
||||||
|
latest.Expression = Math.Max(0, scoreResult.Expression);
|
||||||
|
latest.Persuasiveness = Math.Max(0, scoreResult.Persuasiveness);
|
||||||
|
latest.Status = (int)UserAnswerStatusEnum.Complete;
|
||||||
|
latest.UpdatedBy = "AI";
|
||||||
|
latest.UpdatedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await client.Updateable(latest)
|
||||||
|
.IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt })
|
||||||
|
.Where(a => a.Id == latest.Id)
|
||||||
|
.ExecuteCommandAsync(cancellationToken);
|
||||||
|
|
||||||
|
client.Ado.CommitTran();
|
||||||
|
logger.LogInformation("期刊AI批改完成,AnswerId: {AnswerId}, Score: {Score}", latest.Id, latest.Score);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
client.Ado.RollbackTran();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JournalScoreUnit> BuildScoreUnitAsync(
|
||||||
|
ISqlSugarClient client,
|
||||||
|
JournalPageTaskUserAnswer answer,
|
||||||
|
JournalPageTask task,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var groupId = task.GroupId > 0 ? task.GroupId : task.Id;
|
||||||
|
var groupTasks = task.GroupId > 0
|
||||||
|
? await client.Queryable<JournalPageTask>()
|
||||||
|
.Where(t => t.GroupId == task.GroupId && !t.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
: [task];
|
||||||
|
var pageIds = groupTasks.Select(t => t.JournalPageId).Distinct().ToList();
|
||||||
|
var pages = await client.Queryable<JournalPage>()
|
||||||
|
.Where(p => pageIds.Contains(p.Id) && !p.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var pageMap = pages.ToDictionary(p => p.Id);
|
||||||
|
var question = BuildQuestionFromAnswer(answer);
|
||||||
|
var contexts = groupTasks
|
||||||
|
.OrderBy(t => pageMap.TryGetValue(t.JournalPageId, out var page) ? page.PageNum : 0)
|
||||||
|
.ThenBy(t => ParseTaskNo(t.No))
|
||||||
|
.ThenBy(t => t.Id)
|
||||||
|
.Select(t =>
|
||||||
|
{
|
||||||
|
pageMap.TryGetValue(t.JournalPageId, out var page);
|
||||||
|
return new JournalQuestionContext(question, t, page);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new JournalScoreUnit(groupId, contexts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<long, List<JournalPageTaskAnswer>>> BuildReferenceAnswerMapAsync(
|
||||||
|
ISqlSugarClient client,
|
||||||
|
JournalScoreUnit scoreUnit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var taskIds = scoreUnit.Questions.Select(q => q.Task.Id).Distinct().ToList();
|
||||||
|
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
|
||||||
|
.Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return referenceAnswers
|
||||||
|
.GroupBy(a => a.JournalPageTaskId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
|
||||||
|
JournalScoreUnit scoreUnit,
|
||||||
|
Question answerQuestion,
|
||||||
|
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 content = new List<object>
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "text",
|
||||||
|
text = BuildScorePrompt(scoreUnit, referenceAnswerMap)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var answerImages = await BuildAnswerImageContentsAsync(answerQuestion, cancellationToken);
|
||||||
|
if (answerImages.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var context in scoreUnit.Questions)
|
||||||
|
{
|
||||||
|
referenceAnswerMap.TryGetValue(context.Task.Id, out var referenceAnswers);
|
||||||
|
var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(context.Task.Id, referenceAnswers ?? [], cancellationToken);
|
||||||
|
if (referenceAnswerImages.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(new
|
||||||
|
{
|
||||||
|
type = "text",
|
||||||
|
text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。"
|
||||||
|
});
|
||||||
|
foreach (var referenceAnswerImage in referenceAnswerImages)
|
||||||
|
{
|
||||||
|
content.Add(new
|
||||||
|
{
|
||||||
|
type = "image_url",
|
||||||
|
image_url = new { url = referenceAnswerImage.DataUrl }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result = new List<AnswerImageContent>();
|
||||||
|
foreach (var imageUrl in imageUrls)
|
||||||
|
{
|
||||||
|
var imageContent = await BuildImageContentAsync(imageUrl, question.Id, "答案图片", true, cancellationToken);
|
||||||
|
if (imageContent != null)
|
||||||
|
{
|
||||||
|
result.Add(imageContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result = new List<AnswerImageContent>();
|
||||||
|
foreach (var imageUrl in imageUrls)
|
||||||
|
{
|
||||||
|
var imageContent = await BuildImageContentAsync(imageUrl, taskId, "参考答案图片", false, cancellationToken);
|
||||||
|
if (imageContent != null)
|
||||||
|
{
|
||||||
|
result.Add(imageContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AnswerImageContent?> BuildImageContentAsync(
|
||||||
|
string imageUrl,
|
||||||
|
long taskId,
|
||||||
|
string imageRole,
|
||||||
|
bool required,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken);
|
||||||
|
if (imageStream == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{imageRole}读取失败,TaskId: {taskId}, Url: {imageUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
await imageStream.CopyToAsync(memoryStream, cancellationToken);
|
||||||
|
if (memoryStream.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{imageRole}内容为空,TaskId: {taskId}, Url: {imageUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
|
||||||
|
if (maxImageBytes <= 0)
|
||||||
|
{
|
||||||
|
maxImageBytes = DefaultMaxImageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memoryStream.Length > maxImageBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{imageRole}超过大小限制,TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageBytes = memoryStream.ToArray();
|
||||||
|
var mimeType = GetImageMimeType(imageUrl, imageBytes);
|
||||||
|
return new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!required)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "{ImageRole}读取失败,已跳过,TaskId: {TaskId}, Url: {Url}", imageRole, taskId, imageUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Stream?> GetImageStreamAsync(string imageUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (imageUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
imageUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var httpClient = httpClientFactory.CreateClient();
|
||||||
|
return await httpClient.GetStreamAsync(imageUrl, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ossService.GetObjectStream(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) || attempt == maxRetryCount)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (NonRetryAiScoreException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < maxRetryCount)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"AI评分调用失败,TaskId: {taskId}", lastException);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetExtension(imageUrl).ToLowerInvariant() 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 NonRetryAiScoreException("AI评分响应缺少 content");
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentElement.ValueKind == JsonValueKind.String
|
||||||
|
? contentElement.GetString() ?? string.Empty
|
||||||
|
: 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 NonRetryAiScoreException("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 NonRetryAiScoreException($"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 Question BuildQuestionFromAnswer(JournalPageTaskUserAnswer answer)
|
||||||
|
{
|
||||||
|
return new Question
|
||||||
|
{
|
||||||
|
Id = answer.JournalPageTaskId,
|
||||||
|
Url = answer.QuestionAnswerUrl ?? string.Empty,
|
||||||
|
AnswerUrl = DeserializeStringArray(answer.AnswerUrl),
|
||||||
|
AnswerStartTime = answer.AnswerStartTime,
|
||||||
|
AnswerEndTime = answer.AnswerEndTime,
|
||||||
|
AnswerTime = answer.AnswerSeconds,
|
||||||
|
BreakCount = answer.BreakCount,
|
||||||
|
BreakTimes = DeserializeBreakTimes(answer.BreakTimes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] DeserializeStringArray(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<string[]>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BreakTime> DeserializeBreakTimes(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<BreakTime>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 int GetPendingAnswerMinutes()
|
||||||
|
{
|
||||||
|
var minutes = configuration.GetValue<int>("AiChat:PendingAnswerMinutes");
|
||||||
|
return minutes > 0 ? minutes : DefaultPendingAnswerMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetPendingAnswerBatchSize()
|
||||||
|
{
|
||||||
|
var batchSize = configuration.GetValue<int>("AiChat:PendingAnswerBatchSize");
|
||||||
|
return batchSize > 0 ? batchSize : DefaultPendingAnswerBatchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime GetWindowStart(DateTime windowEnd)
|
||||||
|
{
|
||||||
|
lock (RunWindowLock)
|
||||||
|
{
|
||||||
|
return lastRunAt ?? windowEnd.AddMinutes(-GetPendingAnswerMinutes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetLastRunAt(DateTime runAt)
|
||||||
|
{
|
||||||
|
lock (RunWindowLock)
|
||||||
|
{
|
||||||
|
lastRunAt = runAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetPendingTime(JournalPageTaskUserAnswer answer)
|
||||||
|
{
|
||||||
|
return answer.UpdatedAt ?? answer.CreatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseTaskNo(string? taskNo)
|
||||||
|
{
|
||||||
|
return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TrimResult(string? result)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(result))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Length <= 50 ? result : result[..50];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,6 +47,14 @@
|
|||||||
"Cron": "* * * * *",
|
"Cron": "* * * * *",
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Description": "示例任务(默认关闭,仅用于验证框架运行)"
|
"Description": "示例任务(默认关闭,仅用于验证框架运行)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "journal-task-ai-score-job",
|
||||||
|
"JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.JournalTaskAiScoreJob",
|
||||||
|
"MethodName": "Execute",
|
||||||
|
"Cron": "*/30 * * * *",
|
||||||
|
"Enabled": true,
|
||||||
|
"Description": "每30分钟扫描上次执行到本次执行之间的期刊答题记录并提交AI批改"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -62,7 +70,8 @@
|
|||||||
"ScoreRetryDelayMilliseconds": 1000,
|
"ScoreRetryDelayMilliseconds": 1000,
|
||||||
"MaxImageBytes": 10485760,
|
"MaxImageBytes": 10485760,
|
||||||
"CompletionThreshold": 80,
|
"CompletionThreshold": 80,
|
||||||
"CommunityScoreThreshold": 90
|
"PendingAnswerMinutes": 30,
|
||||||
|
"PendingAnswerBatchSize": 100
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"AliyunOSSConfigs": {
|
"AliyunOSSConfigs": {
|
||||||
|
|||||||
Reference in New Issue
Block a user