From 6ec47220c2d3a608df287d1388fcb74ea95bedfe Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Tue, 30 Jun 2026 16:16:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EAI=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=89=B9=E6=94=B9=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=AF=84=E5=88=86=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加AI评分配置节点到appsettings.json 2. 新增NeedAiProcess字段控制是否使用AI批改 3. 扩展答题记录实体,新增理解力、判断力、表达力、说服力评分字段 4. 实现期刊任务AI自动批改消费者逻辑,支持重试机制和图片处理 5. 优化实体类注释和代码结构 6. 新增Dockerfile用于容器化部署 --- .../Dto/Journal/JournalPageTaskAddInput.cs | 5 + .../Dto/Journal/JournalPageTaskOutput.cs | 5 + .../Dto/Journal/JournalPageTaskUpdateInput.cs | 5 + .../Entity/JournalPageTaskUserAnswer.cs | 95 ++- .../JournalPageTaskUserAnswerSnapshot.cs | 22 +- .../JournalPageTaskService.cs | 6 +- .../Consumers/JournalTaskReceiveConsumer.cs | 769 +++++++++++++++++- .../Dockerfile | 16 + .../appsettings.json | 11 + 9 files changed, 880 insertions(+), 54 deletions(-) create mode 100644 QYZH.InteractiveMagazine.WorkService/Dockerfile diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs index e910085..a6dda42 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs @@ -29,5 +29,10 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public TaskBankTypeEnum Type { get; set; } + /// + /// 是否需要AI批改,默认需要 + /// + public bool NeedAiProcess { get; set; } = true; + } } diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs index 7bb9e41..44f11b6 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs @@ -74,6 +74,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// Prompt配置 /// public string Prompt { get; set; } + + /// + /// 是否需要AI批改 + /// + public bool NeedAiProcess { get; set; } } /// diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs index c797da2..9cbaf60 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs @@ -71,6 +71,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public string Prompt { get; set; } + /// + /// 是否需要AI批改,false时由人工批改 + /// + public bool NeedAiProcess { get; set; } = true; + } public class JournalPageTaskComplementInput diff --git a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs index dfb1dfd..43f18e7 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs @@ -2,9 +2,9 @@ using SqlSugar; namespace QYZH.InteractiveMagazine.Models.Entity { - /// - ///杂志任务用户作答记录; - /// + /// + /// 杂志任务用户作答记录 + /// [SugarTable("JournalPageTaskUserAnswer")] public class JournalPageTaskUserAnswer : SqlSugarBaseEntity { @@ -12,204 +12,231 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// Desc:书id /// Default: /// Nullable:False - /// + /// public long JournalId { get; set; } /// /// Desc:书页id /// Default: /// Nullable:False - /// + /// public long JournalPageId { get; set; } /// /// Desc:任务id /// Default: /// Nullable:False - /// + /// public long JournalPageTaskId { get; set; } /// /// Desc:任务分组id /// Default: /// Nullable:False - /// + /// public long JournalPageTaskGroupId { get; set; } /// /// Desc:用户id /// Default: /// Nullable:False - /// + /// public long UserId { get; set; } /// /// Desc:作答结果 /// Default: /// Nullable:True - /// + /// public string Result { get; set; } /// /// Desc:积分 /// Default:0 /// Nullable:False - /// - public float Points { get; set; } + /// + public int Points { get; set; } /// /// Desc:成长值 /// Default:0 /// Nullable:False - /// + /// public float GrowthPoints { get; set; } /// /// Desc:题目得分 /// Default:0 /// Nullable:False - /// + /// public float Score { get; set; } + /// + /// Desc:理解力评分 + /// Default:0 + /// Nullable:False + /// + public float Comprehension { get; set; } + + /// + /// Desc:判断力评分 + /// Default:0 + /// Nullable:False + /// + public float Judgment { get; set; } + + /// + /// Desc:表达力评分 + /// Default:0 + /// Nullable:False + /// + public float Expression { get; set; } + + /// + /// Desc:说服力评分 + /// Default:0 + /// Nullable:False + /// + public float Persuasiveness { get; set; } + /// /// Desc:题目作答图片地址 /// Default: /// Nullable:True - /// + /// public string QuestionAnswerUrl { get; set; } /// /// Desc:答案图片地址 /// Default: /// Nullable:True - /// + /// public string AnswerUrl { get; set; } /// /// Desc:页面作答图片地址 /// Default: /// Nullable:True - /// + /// public string PageAnswerUrl { get; set; } /// /// Desc:乐观锁 /// Default: /// Nullable:False - /// + /// public int Revision { get; set; } /// /// Desc:作答状态 /// Default: /// Nullable:False - /// + /// public int AnswerStatus { get; set; } /// /// Desc:作答开始时间 /// Default: /// Nullable:True - /// + /// public DateTime AnswerStartTime { get; set; } /// /// Desc:作答结束时间 /// Default: /// Nullable:True - /// + /// public DateTime AnswerEndTime { get; set; } /// /// Desc:作答耗时(秒) /// Default:0 /// Nullable:False - /// + /// public int AnswerSeconds { get; set; } /// /// Desc:图像识别结果 /// Default: /// Nullable:False - /// + /// public int ImageRecognition { get; set; } /// /// Desc:书页序号 /// Default: /// Nullable:False - /// + /// public int JournalPageNum { get; set; } /// /// Desc:修改次数 /// Default: /// Nullable:False - /// + /// public int Modify { get; set; } /// /// Desc:最后标签id /// Default: /// Nullable:False - /// + /// public long LastTag { get; set; } /// /// Desc:点读书页序号 /// Default: /// Nullable:False - /// + /// public int DotPageNum { get; set; } /// /// Desc:页面结果图片地址 /// Default: /// Nullable:True - /// + /// public string PageResultUrl { get; set; } /// /// Desc:题型 /// Default: /// Nullable:True - /// + /// public string Type { get; set; } /// /// Desc:点读书页编号 /// Default: /// Nullable:True - /// + /// public string DotPageNo { get; set; } /// /// Desc:页面作答点读图片地址 /// Default: /// Nullable:True - /// + /// public string PageAnswerDotUrl { get; set; } /// /// Desc:中断次数 /// Default:0 /// Nullable:False - /// + /// public int BreakCount { get; set; } /// /// Desc:中断时间记录 /// Default: /// Nullable:True - /// + /// public string BreakTimes { get; set; } /// /// Desc:作业状态 /// Default: /// Nullable:True - /// + /// public string AssignmentStatus { get; set; } - } } diff --git a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs index 300176e..6bf3ef3 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs @@ -46,7 +46,7 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// /// Points awarded. /// - public float Points { get; set; } + public int Points { get; set; } /// /// Growth points awarded. @@ -58,6 +58,26 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// public float Score { get; set; } + /// + /// Comprehension score. + /// + public float Comprehension { get; set; } + + /// + /// Judgment score. + /// + public float Judgment { get; set; } + + /// + /// Expression score. + /// + public float Expression { get; set; } + + /// + /// Persuasiveness score. + /// + public float Persuasiveness { get; set; } + /// /// Question answer image URL. /// diff --git a/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs b/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs index efb5ae4..4976a0a 100644 --- a/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs +++ b/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs @@ -24,6 +24,7 @@ public class JournalPageTaskService(BaseRepository journalRepository, O var map = input.Adapt(); map.Id = YitIdHelper.NextId(); map.GroupId = map.Id; + map.NeedAiProcess = input.NeedAiProcess; var no = input.No.Split('-').Select(int.Parse).ToArray(); if (no.Last() >= 2) { @@ -59,7 +60,6 @@ public class JournalPageTaskService(BaseRepository journalRepository, O task.Type = input.Type; task.Task = input.Task; - task.No = input.No; task.Points = input.Points; task.GrowthPoint = (int?)input.GrowthPoint; task.Comprehension = input.Comprehension; @@ -68,6 +68,7 @@ public class JournalPageTaskService(BaseRepository journalRepository, O task.Persuasiveness = input.Persuasiveness; task.AnswerTime = input.AnswerTime; task.Prompt = input.Prompt; + task.NeedAiProcess = input.NeedAiProcess; var no = input.No.Split('-').Select(int.Parse).ToArray(); @@ -104,7 +105,8 @@ public class JournalPageTaskService(BaseRepository journalRepository, O Expression = task.Expression, Persuasiveness = task.Persuasiveness, AnswerTime = task.AnswerTime, - Prompt = task.Prompt + Prompt = task.Prompt, + NeedAiProcess = task.NeedAiProcess }; // 查询答案列表 diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index a44f653..d3267b0 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -1,21 +1,27 @@ -using Newtonsoft.Json; using QYZH.InteractiveMagazine.Infrastructure.OSS; +using QYZH.InteractiveMagazine.Models.Entity; +using QYZH.InteractiveMagazine.Models.Enum; using SqlSugar; +using System.Net.Http.Headers; using System.Text; -using System.Threading.Channels; -using Yitter.IdGenerator; +using System.Text.Json; +using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.WorkService.Consumers; /// -/// 期刊任务接收消费者(示例) +/// 期刊任务接收消费者 /// -public class JournalTaskReceiveConsumer(ILogger logger, IConfiguration configuration, +public class JournalTaskReceiveConsumer( + ILogger logger, + IConfiguration configuration, IServiceScopeFactory scopeFactory, - IWebHostEnvironment webHostEnvironment, IHttpClientFactory httpClientFactory, OssService ossService) : IQueueConsumer { + private const int DefaultAiScoreMaxRetryCount = 3; + private const int DefaultAiScoreRetryDelayMilliseconds = 1000; + private const long DefaultMaxImageBytes = 10 * 1024 * 1024; public string Exchange => "ex.journal"; @@ -30,21 +36,98 @@ public class JournalTaskReceiveConsumer(ILogger logg using var scope = scopeFactory.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - // TODO: 在此编写具体的消息处理逻辑 + var data = JsonSerializer.Deserialize(message, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidOperationException("期刊任务消息内容为空"); + + if (data.Questions == null || data.Questions.Length == 0) + { + logger.LogWarning("期刊任务消息没有题目,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}", data.UserId, data.JournalId, data.PageId); + return; + } + + var taskIds = data.Questions.Select(q => q.Id).Distinct().ToList(); + var tasks = await client.Queryable() + .Where(t => taskIds.Contains(t.Id) && !t.IsDeleted) + .ToListAsync(cancellationToken); + var taskMap = tasks.ToDictionary(t => t.Id); + var referenceAnswers = await client.Queryable() + .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) + .ToListAsync(cancellationToken); + var referenceAnswerMap = referenceAnswers + .GroupBy(a => a.JournalPageTaskId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var page = await client.Queryable() + .Where(p => p.Id == data.PageId && !p.IsDeleted) + .FirstAsync(cancellationToken); + + var answerEntities = new List(); + foreach (var question in data.Questions) + { + if (!taskMap.TryGetValue(question.Id, out var task)) + { + logger.LogWarning("未找到期刊任务,TaskId: {TaskId}, UserId: {UserId}", question.Id, data.UserId); + continue; + } + + if (!task.NeedAiProcess) + { + logger.LogInformation("期刊任务配置为人工批改,跳过AI评分,TaskId: {TaskId}, UserId: {UserId}", task.Id, data.UserId); + continue; + } + + referenceAnswerMap.TryGetValue(task.Id, out var taskReferenceAnswers); + var scoreResult = await ScoreQuestionAsync(task, question, taskReferenceAnswers ?? [], cancellationToken); + answerEntities.Add(BuildAnswerEntity(data, question, task, page, scoreResult)); + } + + if (answerEntities.Count == 0) + { + logger.LogWarning("期刊任务消息没有可入库的答题记录,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}", data.UserId, data.JournalId, data.PageId); + return; + } + + client.Ado.BeginTran(); try { - var data = System.Text.Json.JsonSerializer.Deserialize(message); + foreach (var answer in answerEntities) + { + var existing = await client.Queryable() + .Where(a => a.UserId == answer.UserId && a.JournalPageTaskId == answer.JournalPageTaskId && !a.IsDeleted) + .FirstAsync(cancellationToken); + if (existing == null) + { + await client.Insertable(answer).ExecuteCommandAsync(cancellationToken); + continue; + } + 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); + } client.Ado.CommitTran(); } - catch (Exception ex) + catch { client.Ado.RollbackTran(); - logger.LogError(ex.Message + ex.StackTrace); + throw; } - await Task.CompletedTask; + + logger.LogInformation("期刊任务答题记录保存完成,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}", + data.UserId, data.JournalId, data.PageId, answerEntities.Count); } public Task OnErrorAsync(byte[] body, Exception exception) @@ -54,32 +137,684 @@ public class JournalTaskReceiveConsumer(ILogger logg return Task.CompletedTask; } + private async Task ScoreQuestionAsync( + JournalPageTask task, + Question question, + List referenceAnswers, + CancellationToken cancellationToken) + { + var apiKey = configuration["AiChat:ApiKey"]; + var baseUrl = configuration["AiChat:BaseUrl"]; + var model = configuration["AiChat:Model"]; + var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); + var maxTokens = configuration.GetValue("AiChat:MaxTokens"); + var temperature = configuration.GetValue("AiChat:Temperature"); + if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) + { + throw new InvalidOperationException("AI聊天服务配置不完整,请检查 AiChat 配置节点"); + } + + var answerImages = await BuildAnswerImageContentsAsync(question, cancellationToken); + if (answerImages.Count == 0) + { + throw new InvalidOperationException($"题目 {question.Id} 缺少答案图片"); + } + + var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(task.Id, referenceAnswers, cancellationToken); + + var content = new List + { + new + { + type = "text", + text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count) + } + }; + + foreach (var answerImage in answerImages) + { + content.Add(new + { + type = "image_url", + image_url = new { url = answerImage.DataUrl } + }); + } + + if (referenceAnswerImages.Count > 0) + { + content.Add(new + { + type = "text", + text = $"以下为参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。" + }); + + 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 > 0 ? temperature : 0.2, + stream = false, + response_format = new { type = "json_object" } + }; + + var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + + var responseContent = await SendAiScoreRequestWithRetryAsync( + task.Id, + $"{baseUrl.TrimEnd('/')}/chat/completions", + apiKey, + requestJson, + timeoutSeconds > 0 ? timeoutSeconds : 300, + cancellationToken); + var resultJson = ExtractAssistantContent(responseContent); + var scoreResult = ParseScoreResult(resultJson); + scoreResult.Result = TrimResult(scoreResult.Result); + return scoreResult; + } + + private static string BuildScorePrompt( + JournalPageTask task, + Question question, + int answerImageCount, + List referenceAnswers, + int referenceAnswerImageCount) + { + var referenceAnswerTexts = referenceAnswers + .Select(a => a.Answer?.Trim()) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct() + .ToList(); + + var prompt = new StringBuilder(); + prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。"); + prompt.AppendLine($"参考答案图片数量:{referenceAnswerImageCount}"); + if (referenceAnswerTexts.Count > 0) + { + prompt.AppendLine("参考答案文本:"); + for (var i = 0; i < referenceAnswerTexts.Count; i++) + { + prompt.AppendLine($"{i + 1}. {referenceAnswerTexts[i]}"); + } + } + else + { + prompt.AppendLine("参考答案文本:无"); + } + prompt.AppendLine(); + prompt.AppendLine("请根据题目评分 Prompt 和学生答案图片进行评分。"); + prompt.AppendLine(); + prompt.AppendLine($"题目内容:{task.Task}"); + prompt.AppendLine("评分Prompt:"); + prompt.AppendLine(task.Prompt); + prompt.AppendLine(); + prompt.AppendLine("题目配置:"); + prompt.AppendLine($"- 成长值上限:{task.GrowthPoint ?? 0}"); + prompt.AppendLine($"- 积分上限:{task.Points}"); + prompt.AppendLine($"- 理解力上限:{task.Comprehension}"); + prompt.AppendLine($"- 判断力上限:{task.Judgment}"); + prompt.AppendLine($"- 表达力上限:{task.Expression}"); + prompt.AppendLine($"- 说服力上限:{task.Persuasiveness}"); + prompt.AppendLine(); + prompt.AppendLine($"学生答案图片数量:{answerImageCount}"); + 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(" \"Result\": \"50字内的中文评语\""); + prompt.AppendLine("}"); + return prompt.ToString(); + } + + private async Task> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken) + { + var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? []; + var maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); + if (maxImageBytes <= 0) + { + maxImageBytes = DefaultMaxImageBytes; + } + + var result = new List(); + foreach (var imageUrl in imageUrls) + { + cancellationToken.ThrowIfCancellationRequested(); + + await using var imageStream = ossService.GetObjectStream(imageUrl); + 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> BuildReferenceAnswerImageContentsAsync( + long taskId, + List referenceAnswers, + CancellationToken cancellationToken) + { + var imageUrls = referenceAnswers + .Select(a => a.AnswerUrL) + .Where(url => !string.IsNullOrWhiteSpace(url)) + .Distinct() + .Select(url => url!) + .ToList(); + var maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); + if (maxImageBytes <= 0) + { + maxImageBytes = DefaultMaxImageBytes; + } + + var result = new List(); + foreach (var imageUrl in imageUrls) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await using var imageStream = ossService.GetObjectStream(imageUrl); + 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 SendAiScoreRequestWithRetryAsync( + long taskId, + string requestUrl, + string apiKey, + string requestJson, + int timeoutSeconds, + CancellationToken cancellationToken) + { + var maxRetryCount = configuration.GetValue("AiChat:ScoreMaxRetryCount"); + if (maxRetryCount <= 0) + { + maxRetryCount = DefaultAiScoreMaxRetryCount; + } + + var retryDelayMilliseconds = configuration.GetValue("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) + { + return responseContent; + } + + 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 (Exception ex) when (attempt < maxRetryCount) + { + 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 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); + var result = JsonSerializer.Deserialize(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 JournalPageTaskUserAnswer BuildAnswerEntity( + QuestionData data, + Question question, + JournalPageTask task, + JournalPage? page, + JournalAnswerScoreResult scoreResult) + { + var now = DateTime.Now; + var growthPoint = Math.Max(0, scoreResult.GrowthPoint); + var points = Math.Max(0, scoreResult.Points); + + return new JournalPageTaskUserAnswer + { + JournalId = data.JournalId, + JournalPageId = data.PageId, + JournalPageTaskId = task.Id, + JournalPageTaskGroupId = task.GroupId, + UserId = data.UserId, + Result = scoreResult.Result, + Points = points, + GrowthPoints = growthPoint, + Score = Math.Max(0, scoreResult.Score), + Comprehension = Math.Max(0, scoreResult.Comprehension), + Judgment = Math.Max(0, scoreResult.Judgment), + Expression = Math.Max(0, scoreResult.Expression), + Persuasiveness = Math.Max(0, scoreResult.Persuasiveness), + QuestionAnswerUrl = question.Url, + AnswerUrl = JsonSerializer.Serialize(question.AnswerUrl ?? []), + PageAnswerUrl = data.PageAnswerUrl, + Revision = 0, + AnswerStatus = (int)UserAnswerStatusEnum.Complete, + AnswerStartTime = question.AnswerStartTime, + AnswerEndTime = question.AnswerEndTime, + AnswerSeconds = question.AnswerTime, + ImageRecognition = 0, + JournalPageNum = page?.PageNum ?? 0, + Modify = 0, + LastTag = 0, + DotPageNum = page?.PageNum ?? 0, + PageResultUrl = string.Empty, + Type = task.Type.ToString(), + DotPageNo = page?.PageNo ?? string.Empty, + PageAnswerDotUrl = string.Empty, + BreakCount = question.BreakCount, + BreakTimes = JsonSerializer.Serialize(question.BreakTimes ?? []), + AssignmentStatus = UserAnswerStatusEnum.Complete.ToString(), + Status = (int)UserAnswerStatusEnum.Complete, + CreatedBy = data.UserId.ToString(), + CreatedAt = data.CreatedTime == default ? now : data.CreatedTime, + UpdatedBy = data.UserId.ToString(), + UpdatedAt = now + }; + } + + 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, + AnswerStatus = answer.AnswerStatus, + 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 static string TrimResult(string? result) + { + if (string.IsNullOrWhiteSpace(result)) + { + return string.Empty; + } + + return result.Length <= 50 ? result : result[..50]; + } } +/// +/// 期刊题目作答消息 +/// public class QuestionData { + /// + /// 用户ID + /// public long UserId { get; set; } + + /// + /// 期刊ID + /// public long JournalId { get; set; } + + /// + /// 页ID + /// public long PageId { get; set; } - public string PageAnswerUrl { get; set; } - public Question[] Questions { get; set; } + + /// + /// 页面作答图片地址 + /// + public string PageAnswerUrl { get; set; } = string.Empty; + + /// + /// 题目作答列表 + /// + public Question[] Questions { get; set; } = []; + + /// + /// 创建时间 + /// public DateTime CreatedTime { get; set; } } +/// +/// 题目作答数据 +/// public class Question { + /// + /// 题目ID + /// public long Id { get; set; } - public string Url { get; set; } - public string[] AnswerUrl { get; set; } + + /// + /// 题目图片地址 + /// + public string Url { get; set; } = string.Empty; + + /// + /// 答案图片地址 + /// + public string[] AnswerUrl { get; set; } = []; + + /// + /// 作答开始时间 + /// public DateTime AnswerStartTime { get; set; } + + /// + /// 作答结束时间 + /// public DateTime AnswerEndTime { get; set; } + + /// + /// 作答耗时秒数 + /// public int AnswerTime { get; set; } + + /// + /// 中断次数 + /// public int BreakCount { get; set; } - public List BreakTimes { get; set; } + + /// + /// 中断记录 + /// + public List BreakTimes { get; set; } = []; } + +/// +/// 中断时间记录 +/// public class BreakTime { + /// + /// 中断时间 + /// public DateTime Time { get; set; } + + /// + /// 等待时间 + /// public long WaitTime { get; set; } -} \ No newline at end of file +} + +/// +/// AI评分结果 +/// +public class JournalAnswerScoreResult +{ + /// + /// 题目得分 + /// + public float Score { get; set; } + + /// + /// 成长值 + /// + public int GrowthPoint { get; set; } + + /// + /// 积分 + /// + public int Points { get; set; } + + /// + /// 理解力评分 + /// + public float Comprehension { get; set; } + + /// + /// 判断力评分 + /// + public float Judgment { get; set; } + + /// + /// 表达力评分 + /// + public float Expression { get; set; } + + /// + /// 说服力评分 + /// + public float Persuasiveness { get; set; } + + /// + /// 50字内评语 + /// + public string Result { get; set; } = string.Empty; +} + +public record AnswerImageContent(string DataUrl); diff --git a/QYZH.InteractiveMagazine.WorkService/Dockerfile b/QYZH.InteractiveMagazine.WorkService/Dockerfile new file mode 100644 index 0000000..c4ca07a --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/Dockerfile @@ -0,0 +1,16 @@ +# 使用 ASP.NET Core 8.0 运行时基础镜像 +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + +# 设置工作目录 +WORKDIR /app + +# 将当前目录(发布文件夹)的所有内容复制到容器内的 /app 目录 +COPY . . + +# 设置时区(可选) +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + + +# 启动应用 +ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WorkService.dll"] \ No newline at end of file diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index af6d9bf..5658ea6 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -49,6 +49,17 @@ } ] }, + "AiChat": { + "ApiKey": "Ollama", + "BaseUrl": "http://172.16.10.130:11434/v1/", + "Model": "qwen2.5vl:7b", + "TimeoutSeconds": 300, + "MaxTokens": 2000, + "Temperature": 0.5, + "ScoreMaxRetryCount": 3, + "ScoreRetryDelayMilliseconds": 1000, + "MaxImageBytes": 10485760 + }, "AllowedHosts": "*", "AliyunOSSConfigs": { "AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf",