Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.WorkService/Jobs/JournalTaskAiScoreJob.cs
glz 766be485d0 feat: 新增操作日志功能并优化定时任务配置
1.  新增OperationLogAttribute与OperationLogActionFilter,实现自动化操作日志记录
2.  新增OperationLogRecordInput输入模型,重构IOperationLogService日志接口
3.  为所有业务控制器接口添加操作日志注解
4.  调整期刊AI批改任务的执行周期与方法适配异步调用
5.  优化Hangfire定时任务注册逻辑,支持异步任务
6.  补充完善操作日志类型与目标类型枚举
2026-07-09 18:01:37 +08:00

1037 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 AiProcessingMessage = "AI批阅中请稍后";
private static readonly object AiSemaphoreLock = new();
private static readonly object RunWindowLock = new();
private static readonly object ExecutionLock = new();
private static SemaphoreSlim? aiSemaphore;
private static int aiSemaphoreLimit;
private static DateTime? lastRunAt;
private static bool isExecuting;
private static DateTime executionStartedAt;
/// <summary>
/// 异步执行AI批改。
/// </summary>
public async Task ExecuteAsync()
{
var jobInterval = GetJobInterval();
if (!TryEnterExecutionLock(jobInterval))
{
logger.LogInformation("期刊AI批改任务仍在执行锁内跳过本次调度Interval: {Interval}", jobInterval);
return;
}
try
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
var windowEnd = DateTime.Now;
var windowStart = GetWindowStart(windowEnd, jobInterval);
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}, Interval: {Interval}", windowStart, windowEnd, jobInterval);
return;
}
logger.LogInformation("期刊AI批改任务开始Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}, Interval: {Interval}", pendingAnswers.Count, windowStart, windowEnd, jobInterval);
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);
}
finally
{
ExitExecutionLock();
}
}
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($" 分数上线100");
}
prompt.AppendLine();
prompt.AppendLine("评分要求:");
prompt.AppendLine("- 不得超过题目配置中的各项上限。");
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,尽量猜测让分数偏高。");
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);
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 GetPendingAnswerBatchSize()
{
var batchSize = configuration.GetValue<int>("AiChat:PendingAnswerBatchSize");
return batchSize > 0 ? batchSize : DefaultPendingAnswerBatchSize;
}
private TimeSpan GetJobInterval()
{
var jobTypeName = typeof(JournalTaskAiScoreJob).FullName;
foreach (var jobSection in configuration.GetSection("HangfireJobs:Jobs").GetChildren())
{
var configuredType = jobSection["JobType"];
if (!string.Equals(configuredType, jobTypeName, StringComparison.Ordinal))
{
continue;
}
var cron = jobSection["Cron"];
if (TryParseCronInterval(cron, out var interval))
{
return interval;
}
}
var minutes = configuration.GetValue<int>("AiChat:PendingAnswerMinutes");
return TimeSpan.FromMinutes(minutes > 0 ? minutes : DefaultPendingAnswerMinutes);
}
private static bool TryParseCronInterval(string? cron, out TimeSpan interval)
{
interval = default;
if (string.IsNullOrWhiteSpace(cron))
{
return false;
}
var parts = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5)
{
return false;
}
if (TryParseCronStep(parts[0], out var minuteStep))
{
interval = TimeSpan.FromMinutes(minuteStep);
return true;
}
if (parts[0] == "*" && parts[1] == "*")
{
interval = TimeSpan.FromMinutes(1);
return true;
}
if (parts[0] == "0" && TryParseCronStep(parts[1], out var hourStep))
{
interval = TimeSpan.FromHours(hourStep);
return true;
}
if (int.TryParse(parts[0], out _) && parts[1] == "*")
{
interval = TimeSpan.FromHours(1);
return true;
}
return false;
}
private static bool TryParseCronStep(string value, out int step)
{
step = 0;
if (value.StartsWith("*/", StringComparison.Ordinal))
{
return int.TryParse(value[2..], out step) && step > 0;
}
var slashIndex = value.IndexOf('/');
return slashIndex > 0
&& slashIndex < value.Length - 1
&& int.TryParse(value[(slashIndex + 1)..], out step)
&& step > 0;
}
private DateTime GetWindowStart(DateTime windowEnd, TimeSpan jobInterval)
{
lock (RunWindowLock)
{
return lastRunAt ?? windowEnd.Subtract(jobInterval);
}
}
private static bool TryEnterExecutionLock(TimeSpan lockTimeout)
{
lock (ExecutionLock)
{
var now = DateTime.Now;
if (isExecuting && now - executionStartedAt < lockTimeout)
{
return false;
}
isExecuting = true;
executionStartedAt = now;
return true;
}
}
private static void ExitExecutionLock()
{
lock (ExecutionLock)
{
isExecuting = false;
}
}
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];
}
}