refactor: 优化AI评分与二维码ID生成逻辑,调整RabbitMQ配置
1. 调整RabbitMQ预取计数配置,支持从配置读取 2. 新增随机ID帮助类,生成唯一长整型ID 3. 重构二维码ID生成逻辑,新增重试机制避免重复 4. 优化AI评分配置,调整温度系数与并发限制 5. 重构跨页题评分逻辑,支持分组评分与结果去重 6. 新增AI评分异常分类与结果校验逻辑 7. 优化评分提示词与结果归一化处理
This commit is contained in:
37
QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs
Normal file
37
QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace QYZH.InteractiveMagazine.Common.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 随机ID帮助类
|
||||||
|
/// </summary>
|
||||||
|
public static class RandomIdHelper
|
||||||
|
{
|
||||||
|
private const long DefaultMinValue = 1_000_000_000_000_000_000L;
|
||||||
|
private const long DefaultMaxValue = 9_000_000_000_000_000_000L;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成不可预测的正数长整型ID
|
||||||
|
/// </summary>
|
||||||
|
public static long GenerateLongId(long minValue = DefaultMinValue, long maxValue = DefaultMaxValue)
|
||||||
|
{
|
||||||
|
if (minValue <= 0 || minValue >= maxValue)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minValue), "随机ID范围配置错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = (ulong)(maxValue - minValue);
|
||||||
|
var limit = ulong.MaxValue - (ulong.MaxValue % range);
|
||||||
|
|
||||||
|
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
var value = BitConverter.ToUInt64(bytes);
|
||||||
|
if (value < limit)
|
||||||
|
{
|
||||||
|
return minValue + (long)(value % range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using RabbitMQ.Client.Events;
|
using RabbitMQ.Client.Events;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@ -9,13 +10,15 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
|
|||||||
public class RabbitMQService : IRabbitMQService
|
public class RabbitMQService : IRabbitMQService
|
||||||
{
|
{
|
||||||
private readonly IRabbitMQConnection _connection;
|
private readonly IRabbitMQConnection _connection;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly JsonSerializerOptions options = new JsonSerializerOptions
|
private readonly JsonSerializerOptions options = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
};
|
};
|
||||||
public RabbitMQService(IRabbitMQConnection connection)
|
public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -130,7 +133,13 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
|
|||||||
public async Task ReceiveAsync(string exchange, string queueName, string routingKey, Func<IChannel, BasicDeliverEventArgs, Task> callback, CancellationToken cancellationToken = default)
|
public async Task ReceiveAsync(string exchange, string queueName, string routingKey, Func<IChannel, BasicDeliverEventArgs, Task> callback, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var channel = await _connection.CreateChannel();
|
var channel = await _connection.CreateChannel();
|
||||||
//await channel.BasicQosAsync(0, 10, false); // 一次最多接收10条未确认的消息
|
var prefetchCount = _configuration.GetValue<ushort>("RabbitMq:PrefetchCount");
|
||||||
|
if (prefetchCount == 0)
|
||||||
|
{
|
||||||
|
prefetchCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.BasicQosAsync(0, prefetchCount, false, cancellationToken);
|
||||||
|
|
||||||
// 声明 Exchange(持久化)
|
// 声明 Exchange(持久化)
|
||||||
await channel.ExchangeDeclareAsync(exchange: exchange, type: "direct", durable: true, autoDelete: false, arguments: null);
|
await channel.ExchangeDeclareAsync(exchange: exchange, type: "direct", durable: true, autoDelete: false, arguments: null);
|
||||||
|
|||||||
@ -32,6 +32,7 @@ public class UserJournalService(
|
|||||||
private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate";
|
private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate";
|
||||||
private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate";
|
private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate";
|
||||||
private const int MaxBatchQrCodeCount = 500;
|
private const int MaxBatchQrCodeCount = 500;
|
||||||
|
private const int MaxRandomIdGenerateRetryCount = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户绑定期刊(扫码绑定)
|
/// 用户绑定期刊(扫码绑定)
|
||||||
@ -183,8 +184,10 @@ public class UserJournalService(
|
|||||||
throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY);
|
throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recordId = await GenerateUniqueQrCodeIdAsync();
|
||||||
var record = new UserJournal
|
var record = new UserJournal
|
||||||
{
|
{
|
||||||
|
Id = recordId,
|
||||||
UserId = null,
|
UserId = null,
|
||||||
JournalId = input.JournalId,
|
JournalId = input.JournalId,
|
||||||
Type = 0,
|
Type = 0,
|
||||||
@ -243,9 +246,11 @@ public class UserJournalService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var records = Enumerable.Range(0, input.Count)
|
var randomIds = await GenerateUniqueQrCodeIdsAsync(input.Count);
|
||||||
.Select(_ => new UserJournal
|
var records = randomIds
|
||||||
|
.Select(id => new UserJournal
|
||||||
{
|
{
|
||||||
|
Id = id,
|
||||||
UserId = null,
|
UserId = null,
|
||||||
JournalId = input.JournalId,
|
JournalId = input.JournalId,
|
||||||
Type = 0,
|
Type = 0,
|
||||||
@ -459,6 +464,51 @@ public class UserJournalService(
|
|||||||
return JsonSerializer.Serialize(new { JournalId = journalId, Id = id });
|
return JsonSerializer.Serialize(new { JournalId = journalId, Id = id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<long> GenerateUniqueQrCodeIdAsync()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < MaxRandomIdGenerateRetryCount; i++)
|
||||||
|
{
|
||||||
|
var id = RandomIdHelper.GenerateLongId();
|
||||||
|
var exists = await userJournalRepository.Queryable()
|
||||||
|
.AnyAsync(uj => uj.Id == id);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<long>> GenerateUniqueQrCodeIdsAsync(int count)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<long>();
|
||||||
|
|
||||||
|
for (var i = 0; i < MaxRandomIdGenerateRetryCount && ids.Count < count; i++)
|
||||||
|
{
|
||||||
|
while (ids.Count < count)
|
||||||
|
{
|
||||||
|
ids.Add(RandomIdHelper.GenerateLongId());
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateIds = ids.ToList();
|
||||||
|
var existingIds = await userJournalRepository.Queryable()
|
||||||
|
.Where(uj => candidateIds.Contains(uj.Id))
|
||||||
|
.Select(uj => uj.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (existingIds.Count == 0)
|
||||||
|
{
|
||||||
|
return candidateIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.ExceptWith(existingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SendBindJournalMessageAsync(Users user, Journal journal)
|
private async Task SendBindJournalMessageAsync(Users user, Journal journal)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@ -21,9 +21,14 @@ public class JournalTaskReceiveConsumer(
|
|||||||
{
|
{
|
||||||
private const int DefaultAiScoreMaxRetryCount = 3;
|
private const int DefaultAiScoreMaxRetryCount = 3;
|
||||||
private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
|
private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
|
||||||
|
private const int DefaultAiMaxConcurrency = 1;
|
||||||
private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
|
private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
|
||||||
private const float DefaultCompletionThreshold = 80;
|
private const float DefaultCompletionThreshold = 80;
|
||||||
private const float DefaultCommunityScoreThreshold = 90;
|
private const float DefaultCommunityScoreThreshold = 90;
|
||||||
|
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";
|
||||||
|
|
||||||
@ -54,17 +59,38 @@ public class JournalTaskReceiveConsumer(
|
|||||||
.Where(t => taskIds.Contains(t.Id) && !t.IsDeleted)
|
.Where(t => taskIds.Contains(t.Id) && !t.IsDeleted)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
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 groupTasks = await client.Queryable<JournalPageTask>()
|
||||||
|
.Where(t => groupIds.Contains(t.GroupId) && !t.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var persistedTaskMap = groupTasks
|
||||||
|
.GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.FirstOrDefault(t => t.Id == g.Key)
|
||||||
|
?? g.OrderBy(t => t.Id).First());
|
||||||
|
var groupTaskIds = groupTasks.Select(t => t.Id).Distinct().ToList();
|
||||||
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
|
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
|
||||||
.Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
|
.Where(a => groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var referenceAnswerMap = referenceAnswers
|
var referenceAnswerMap = referenceAnswers
|
||||||
.GroupBy(a => a.JournalPageTaskId)
|
.GroupBy(a => a.JournalPageTaskId)
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var page = await client.Queryable<JournalPage>()
|
var pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList();
|
||||||
.Where(p => p.Id == data.PageId && !p.IsDeleted)
|
var pages = await client.Queryable<JournalPage>()
|
||||||
.FirstAsync(cancellationToken);
|
.Where(p => pageIds.Contains(p.Id) && !p.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var pageMap = pages.ToDictionary(p => p.Id);
|
||||||
|
|
||||||
|
var existingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
|
||||||
|
.Where(a => a.UserId == data.UserId && groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingAnswerMap = existingAnswers
|
||||||
|
.GroupBy(a => a.JournalPageTaskId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First());
|
||||||
|
|
||||||
|
var questionContexts = new List<JournalQuestionContext>();
|
||||||
var answerContexts = new List<JournalAnswerContext>();
|
var answerContexts = new List<JournalAnswerContext>();
|
||||||
foreach (var question in data.Questions)
|
foreach (var question in data.Questions)
|
||||||
{
|
{
|
||||||
@ -80,13 +106,55 @@ public class JournalTaskReceiveConsumer(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
referenceAnswerMap.TryGetValue(task.Id, out var taskReferenceAnswers);
|
pageMap.TryGetValue(task.JournalPageId, out var taskPage);
|
||||||
var scoreResult = await ScoreQuestionAsync(task, question, taskReferenceAnswers ?? [], cancellationToken);
|
questionContexts.Add(new JournalQuestionContext(question, task, taskPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var scoreUnit in BuildScoreUnits(questionContexts))
|
||||||
|
{
|
||||||
|
var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap);
|
||||||
|
if (persistedContext == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("期刊任务评分单元缺少可入库任务,UserId: {UserId}, GroupId: {GroupId}",
|
||||||
|
data.UserId, scoreUnit.GroupId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsCompletedSameAnswer(scoreUnit, persistedContext.Task, data, existingAnswerMap))
|
||||||
|
{
|
||||||
|
logger.LogInformation("期刊任务作答已完成且内容未变化,跳过AI评分,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}",
|
||||||
|
data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scoreResult = await ScoreQuestionAsync(scoreUnit, referenceAnswerMap, cancellationToken);
|
||||||
|
var normalizedResult = NormalizeScoreResult(scoreResult, persistedContext.Task);
|
||||||
answerContexts.Add(new JournalAnswerContext(
|
answerContexts.Add(new JournalAnswerContext(
|
||||||
BuildAnswerEntity(data, question, task, page, scoreResult, GetCompletionThreshold()),
|
BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, normalizedResult, GetCompletionThreshold()),
|
||||||
question,
|
persistedContext.Question,
|
||||||
task,
|
persistedContext.Task,
|
||||||
scoreResult));
|
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)
|
||||||
@ -148,9 +216,8 @@ public class JournalTaskReceiveConsumer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
|
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
|
||||||
JournalPageTask task,
|
JournalScoreUnit scoreUnit,
|
||||||
Question question,
|
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap,
|
||||||
List<JournalPageTaskAnswer> referenceAnswers,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var apiKey = configuration["AiChat:ApiKey"];
|
var apiKey = configuration["AiChat:ApiKey"];
|
||||||
@ -158,30 +225,33 @@ public class JournalTaskReceiveConsumer(
|
|||||||
var model = configuration["AiChat:Model"];
|
var model = configuration["AiChat:Model"];
|
||||||
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
|
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
|
||||||
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
|
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
|
||||||
var temperature = configuration.GetValue<double>("AiChat:Temperature");
|
var temperature = configuration.GetValue<double?>("AiChat:Temperature");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model))
|
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("AI聊天服务配置不完整,请检查 AiChat 配置节点");
|
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<object>
|
var content = new List<object>
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
type = "text",
|
type = "text",
|
||||||
text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count)
|
text = BuildScorePrompt(scoreUnit, referenceAnswerMap)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = $"以下为任务 {context.Task.Id} 的学生作答图片,共 {answerImages.Count} 张。"
|
||||||
|
});
|
||||||
|
|
||||||
foreach (var answerImage in answerImages)
|
foreach (var answerImage in answerImages)
|
||||||
{
|
{
|
||||||
content.Add(new
|
content.Add(new
|
||||||
@ -190,13 +260,26 @@ public class JournalTaskReceiveConsumer(
|
|||||||
image_url = new { url = answerImage.DataUrl }
|
image_url = new { url = answerImage.DataUrl }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (referenceAnswerImages.Count > 0)
|
if (totalAnswerImageCount == 0)
|
||||||
{
|
{
|
||||||
|
throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
content.Add(new
|
||||||
{
|
{
|
||||||
type = "text",
|
type = "text",
|
||||||
text = $"以下为参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。"
|
text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。"
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var referenceAnswerImage in referenceAnswerImages)
|
foreach (var referenceAnswerImage in referenceAnswerImages)
|
||||||
@ -226,7 +309,7 @@ public class JournalTaskReceiveConsumer(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
max_tokens = maxTokens > 0 ? maxTokens : 2000,
|
max_tokens = maxTokens > 0 ? maxTokens : 2000,
|
||||||
temperature = temperature > 0 ? temperature : 0.2,
|
temperature = temperature is >= 0 ? temperature.Value : 0.1,
|
||||||
stream = false,
|
stream = false,
|
||||||
response_format = new { type = "json_object" }
|
response_format = new { type = "json_object" }
|
||||||
};
|
};
|
||||||
@ -236,35 +319,46 @@ public class JournalTaskReceiveConsumer(
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
});
|
});
|
||||||
|
|
||||||
var responseContent = await SendAiScoreRequestWithRetryAsync(
|
var semaphore = GetAiSemaphore();
|
||||||
task.Id,
|
await semaphore.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scoreResult = await SendAiScoreRequestWithRetryAsync(
|
||||||
|
scoreUnit.GroupId,
|
||||||
$"{baseUrl.TrimEnd('/')}/chat/completions",
|
$"{baseUrl.TrimEnd('/')}/chat/completions",
|
||||||
apiKey,
|
apiKey,
|
||||||
requestJson,
|
requestJson,
|
||||||
timeoutSeconds > 0 ? timeoutSeconds : 300,
|
timeoutSeconds > 0 ? timeoutSeconds : 300,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var resultJson = ExtractAssistantContent(responseContent);
|
|
||||||
var scoreResult = ParseScoreResult(resultJson);
|
|
||||||
scoreResult.Result = TrimResult(scoreResult.Result);
|
scoreResult.Result = TrimResult(scoreResult.Result);
|
||||||
return scoreResult;
|
return scoreResult;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildScorePrompt(
|
private static string BuildScorePrompt(
|
||||||
JournalPageTask task,
|
JournalScoreUnit scoreUnit,
|
||||||
Question question,
|
Dictionary<long, List<JournalPageTaskAnswer>> referenceAnswerMap)
|
||||||
int answerImageCount,
|
|
||||||
List<JournalPageTaskAnswer> referenceAnswers,
|
|
||||||
int referenceAnswerImageCount)
|
|
||||||
{
|
{
|
||||||
var referenceAnswerTexts = referenceAnswers
|
var referenceAnswerTexts = scoreUnit.Questions
|
||||||
|
.SelectMany(q =>
|
||||||
|
{
|
||||||
|
referenceAnswerMap.TryGetValue(q.Task.Id, out var answers);
|
||||||
|
return answers ?? [];
|
||||||
|
})
|
||||||
.Select(a => a.Answer?.Trim())
|
.Select(a => a.Answer?.Trim())
|
||||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var prompt = new StringBuilder();
|
var prompt = new StringBuilder();
|
||||||
|
prompt.AppendLine(scoreUnit.Questions.Count > 1
|
||||||
|
? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。"
|
||||||
|
: "这是单页题目作答,请根据学生作答图片评分。");
|
||||||
prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。");
|
prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。");
|
||||||
prompt.AppendLine($"参考答案图片数量:{referenceAnswerImageCount}");
|
|
||||||
if (referenceAnswerTexts.Count > 0)
|
if (referenceAnswerTexts.Count > 0)
|
||||||
{
|
{
|
||||||
prompt.AppendLine("参考答案文本:");
|
prompt.AppendLine("参考答案文本:");
|
||||||
@ -278,21 +372,27 @@ public class JournalTaskReceiveConsumer(
|
|||||||
prompt.AppendLine("参考答案文本:无");
|
prompt.AppendLine("参考答案文本:无");
|
||||||
}
|
}
|
||||||
prompt.AppendLine();
|
prompt.AppendLine();
|
||||||
prompt.AppendLine("请根据题目评分 Prompt 和学生答案图片进行评分。");
|
prompt.AppendLine("题目信息:");
|
||||||
|
foreach (var context in scoreUnit.Questions)
|
||||||
|
{
|
||||||
|
prompt.AppendLine($"- TaskId:{context.Task.Id}");
|
||||||
|
prompt.AppendLine($" 页码:{context.Page?.PageNum ?? 0}");
|
||||||
|
prompt.AppendLine($" 题号:{context.Task.No}");
|
||||||
|
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($"题目内容:{task.Task}");
|
prompt.AppendLine("评分要求:");
|
||||||
prompt.AppendLine("评分Prompt:");
|
prompt.AppendLine("- 不得超过题目配置中的各项上限。");
|
||||||
prompt.AppendLine(task.Prompt);
|
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion,不要猜测高分。");
|
||||||
prompt.AppendLine();
|
prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。");
|
||||||
prompt.AppendLine("题目配置:");
|
prompt.AppendLine("- Result 返回 50 字内中文评语。");
|
||||||
prompt.AppendLine($"- 成长值上限:{task.GrowthPoint}");
|
|
||||||
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();
|
||||||
prompt.AppendLine("只返回如下 JSON 字段:");
|
prompt.AppendLine("只返回如下 JSON 字段:");
|
||||||
prompt.AppendLine("{");
|
prompt.AppendLine("{");
|
||||||
@ -408,7 +508,7 @@ public class JournalTaskReceiveConsumer(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> SendAiScoreRequestWithRetryAsync(
|
private async Task<JournalAnswerScoreResult> SendAiScoreRequestWithRetryAsync(
|
||||||
long taskId,
|
long taskId,
|
||||||
string requestUrl,
|
string requestUrl,
|
||||||
string apiKey,
|
string apiKey,
|
||||||
@ -446,22 +546,37 @@ public class JournalTaskReceiveConsumer(
|
|||||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return responseContent;
|
var resultJson = ExtractAssistantContent(responseContent);
|
||||||
|
return ParseScoreResult(resultJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning("AI评分调用失败,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}",
|
logger.LogWarning("AI评分调用失败,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}",
|
||||||
taskId, attempt, maxRetryCount, response.StatusCode, responseContent);
|
taskId, attempt, maxRetryCount, response.StatusCode, responseContent);
|
||||||
|
|
||||||
if (!ShouldRetry(response.StatusCode) || attempt == maxRetryCount)
|
if (!ShouldRetry(response.StatusCode))
|
||||||
|
{
|
||||||
|
throw new NonRetryAiScoreException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == maxRetryCount)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}");
|
throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (attempt < maxRetryCount)
|
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;
|
lastException = ex;
|
||||||
logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
|
logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount);
|
||||||
@ -536,6 +651,7 @@ public class JournalTaskReceiveConsumer(
|
|||||||
private static JournalAnswerScoreResult ParseScoreResult(string resultJson)
|
private static JournalAnswerScoreResult ParseScoreResult(string resultJson)
|
||||||
{
|
{
|
||||||
var cleanedJson = CleanJsonContent(resultJson);
|
var cleanedJson = CleanJsonContent(resultJson);
|
||||||
|
EnsureRequiredScoreFields(cleanedJson);
|
||||||
var result = JsonSerializer.Deserialize<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
|
var result = JsonSerializer.Deserialize<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
@ -572,8 +688,219 @@ public class JournalTaskReceiveConsumer(
|
|||||||
return text.Trim();
|
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 = TrimResult(scoreResult.Result)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Clamp(float value, float min, float max)
|
||||||
|
{
|
||||||
|
if (float.IsNaN(value) || float.IsInfinity(value))
|
||||||
|
{
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max < min)
|
||||||
|
{
|
||||||
|
max = min;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Min(Math.Max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JournalAnswerScoreResult BuildFailureScoreResult()
|
||||||
|
{
|
||||||
|
return new JournalAnswerScoreResult
|
||||||
|
{
|
||||||
|
Completion = 0,
|
||||||
|
Result = AiProcessingMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JournalScoreUnit> BuildScoreUnits(List<JournalQuestionContext> contexts)
|
||||||
|
{
|
||||||
|
return contexts
|
||||||
|
.GroupBy(c => c.Task.GroupId > 0 ? c.Task.GroupId : c.Task.Id)
|
||||||
|
.Select(g => new JournalScoreUnit(
|
||||||
|
g.Key,
|
||||||
|
g.OrderBy(c => c.Page?.PageNum ?? 0)
|
||||||
|
.ThenBy(c => ParseTaskNo(c.Task.No))
|
||||||
|
.ThenBy(c => c.Task.Id)
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JournalQuestionContext? BuildPersistedContext(
|
||||||
|
JournalScoreUnit scoreUnit,
|
||||||
|
Dictionary<long, JournalPageTask> persistedTaskMap,
|
||||||
|
Dictionary<long, JournalPage> pageMap)
|
||||||
|
{
|
||||||
|
if (scoreUnit.Questions.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceContext = scoreUnit.Questions.FirstOrDefault(q => q.Task.Id == scoreUnit.GroupId)
|
||||||
|
?? scoreUnit.Questions.First();
|
||||||
|
var persistedTask = persistedTaskMap.TryGetValue(scoreUnit.GroupId, out var task)
|
||||||
|
? task
|
||||||
|
: sourceContext.Task;
|
||||||
|
pageMap.TryGetValue(persistedTask.JournalPageId, out var persistedPage);
|
||||||
|
|
||||||
|
return new JournalQuestionContext(sourceContext.Question, persistedTask, persistedPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseTaskNo(string? taskNo)
|
||||||
|
{
|
||||||
|
return int.TryParse(taskNo, out var no) ? no : int.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCompletedSameAnswer(
|
||||||
|
JournalScoreUnit scoreUnit,
|
||||||
|
JournalPageTask persistedTask,
|
||||||
|
QuestionData data,
|
||||||
|
Dictionary<long, JournalPageTaskUserAnswer> existingAnswerMap)
|
||||||
|
{
|
||||||
|
return scoreUnit.Questions.Count > 0
|
||||||
|
&& existingAnswerMap.TryGetValue(persistedTask.Id, out var existing)
|
||||||
|
&& existing.Status == (int)UserAnswerStatusEnum.Complete
|
||||||
|
&& string.Equals(existing.PageAnswerUrl ?? string.Empty, data.PageAnswerUrl ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(existing.AnswerUrl ?? string.Empty, SerializeAnswerUrls(scoreUnit), StringComparison.Ordinal)
|
||||||
|
&& existing.AnswerStartTime == GetAnswerStartTime(scoreUnit)
|
||||||
|
&& existing.AnswerEndTime == GetAnswerEndTime(scoreUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SemaphoreSlim GetAiSemaphore()
|
||||||
|
{
|
||||||
|
var maxConcurrency = configuration.GetValue<int>("AiChat:MaxConcurrency");
|
||||||
|
if (maxConcurrency <= 0)
|
||||||
|
{
|
||||||
|
maxConcurrency = DefaultAiMaxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (AiSemaphoreLock)
|
||||||
|
{
|
||||||
|
if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency)
|
||||||
|
{
|
||||||
|
aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
||||||
|
aiSemaphoreLimit = maxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aiSemaphore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask)
|
||||||
|
{
|
||||||
|
var skippedTaskIds = scoreUnit.Questions
|
||||||
|
.Select(q => q.Task.Id)
|
||||||
|
.Where(id => id != persistedTask.Id)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (skippedTaskIds.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("期刊跨页题评分结果仅保存到主任务,GroupId: {GroupId}, PersistedTaskId: {PersistedTaskId}, SkippedTaskIds: {SkippedTaskIds}",
|
||||||
|
scoreUnit.GroupId, persistedTask.Id, string.Join(",", skippedTaskIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeAnswerUrls(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
var answerUrls = scoreUnit.Questions
|
||||||
|
.SelectMany(q => q.Question.AnswerUrl ?? [])
|
||||||
|
.Where(url => !string.IsNullOrWhiteSpace(url))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(answerUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetAnswerStartTime(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
var startTimes = scoreUnit.Questions
|
||||||
|
.Select(q => q.Question.AnswerStartTime)
|
||||||
|
.Where(t => t != default)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return startTimes.Count == 0 ? default : startTimes.Min();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetAnswerEndTime(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
var endTimes = scoreUnit.Questions
|
||||||
|
.Select(q => q.Question.AnswerEndTime)
|
||||||
|
.Where(t => t != default)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return endTimes.Count == 0 ? default : endTimes.Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetAnswerSeconds(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.AnswerTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetBreakCount(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.BreakCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeBreakTimes(JournalScoreUnit scoreUnit)
|
||||||
|
{
|
||||||
|
var breakTimes = scoreUnit.Questions
|
||||||
|
.SelectMany(q => q.Question.BreakTimes ?? [])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(breakTimes);
|
||||||
|
}
|
||||||
|
|
||||||
private static JournalPageTaskUserAnswer BuildAnswerEntity(
|
private static JournalPageTaskUserAnswer BuildAnswerEntity(
|
||||||
QuestionData data,
|
QuestionData data,
|
||||||
|
JournalScoreUnit scoreUnit,
|
||||||
Question question,
|
Question question,
|
||||||
JournalPageTask task,
|
JournalPageTask task,
|
||||||
JournalPage? page,
|
JournalPage? page,
|
||||||
@ -603,12 +930,12 @@ public class JournalTaskReceiveConsumer(
|
|||||||
Expression = Math.Max(0, scoreResult.Expression),
|
Expression = Math.Max(0, scoreResult.Expression),
|
||||||
Persuasiveness = Math.Max(0, scoreResult.Persuasiveness),
|
Persuasiveness = Math.Max(0, scoreResult.Persuasiveness),
|
||||||
QuestionAnswerUrl = question.Url,
|
QuestionAnswerUrl = question.Url,
|
||||||
AnswerUrl = JsonSerializer.Serialize(question.AnswerUrl ?? []),
|
AnswerUrl = SerializeAnswerUrls(scoreUnit),
|
||||||
PageAnswerUrl = data.PageAnswerUrl,
|
PageAnswerUrl = data.PageAnswerUrl,
|
||||||
Revision = 0,
|
Revision = 0,
|
||||||
AnswerStartTime = question.AnswerStartTime,
|
AnswerStartTime = GetAnswerStartTime(scoreUnit),
|
||||||
AnswerEndTime = question.AnswerEndTime,
|
AnswerEndTime = GetAnswerEndTime(scoreUnit),
|
||||||
AnswerSeconds = question.AnswerTime,
|
AnswerSeconds = GetAnswerSeconds(scoreUnit),
|
||||||
ImageRecognition = 0,
|
ImageRecognition = 0,
|
||||||
JournalPageNum = page?.PageNum ?? 0,
|
JournalPageNum = page?.PageNum ?? 0,
|
||||||
Modify = 0,
|
Modify = 0,
|
||||||
@ -618,8 +945,8 @@ public class JournalTaskReceiveConsumer(
|
|||||||
Type = task.Type.ToString(),
|
Type = task.Type.ToString(),
|
||||||
DotPageNo = page?.PageNo ?? string.Empty,
|
DotPageNo = page?.PageNo ?? string.Empty,
|
||||||
PageAnswerDotUrl = string.Empty,
|
PageAnswerDotUrl = string.Empty,
|
||||||
BreakCount = question.BreakCount,
|
BreakCount = GetBreakCount(scoreUnit),
|
||||||
BreakTimes = JsonSerializer.Serialize(question.BreakTimes ?? []),
|
BreakTimes = SerializeBreakTimes(scoreUnit),
|
||||||
AssignmentStatus = answerStatus.ToString(),
|
AssignmentStatus = answerStatus.ToString(),
|
||||||
Status = (int)answerStatus,
|
Status = (int)answerStatus,
|
||||||
CreatedBy = data.UserId.ToString(),
|
CreatedBy = data.UserId.ToString(),
|
||||||
@ -911,4 +1238,18 @@ public record JournalAnswerContext(
|
|||||||
JournalPageTask Task,
|
JournalPageTask Task,
|
||||||
JournalAnswerScoreResult ScoreResult);
|
JournalAnswerScoreResult ScoreResult);
|
||||||
|
|
||||||
|
public record JournalQuestionContext(
|
||||||
|
Question Question,
|
||||||
|
JournalPageTask Task,
|
||||||
|
JournalPage? Page);
|
||||||
|
|
||||||
|
public record JournalScoreUnit(
|
||||||
|
long GroupId,
|
||||||
|
List<JournalQuestionContext> Questions);
|
||||||
|
|
||||||
public record AnswerImageContent(string DataUrl);
|
public record AnswerImageContent(string DataUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI评分不可重试异常。
|
||||||
|
/// </summary>
|
||||||
|
public class NonRetryAiScoreException(string message) : Exception(message);
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"Port": 5672,
|
"Port": 5672,
|
||||||
"UserName": "smartschool",
|
"UserName": "smartschool",
|
||||||
"Password": "@ss%&*otz%d*pq2S",
|
"Password": "@ss%&*otz%d*pq2S",
|
||||||
"VirtualHost": "InteractiveMagazine"
|
"VirtualHost": "InteractiveMagazine",
|
||||||
|
"PrefetchCount": 1
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
@ -55,7 +56,8 @@
|
|||||||
"Model": "qwen2.5vl:7b",
|
"Model": "qwen2.5vl:7b",
|
||||||
"TimeoutSeconds": 300,
|
"TimeoutSeconds": 300,
|
||||||
"MaxTokens": 2000,
|
"MaxTokens": 2000,
|
||||||
"Temperature": 0.5,
|
"Temperature": 0.1,
|
||||||
|
"MaxConcurrency": 1,
|
||||||
"ScoreMaxRetryCount": 3,
|
"ScoreMaxRetryCount": 3,
|
||||||
"ScoreRetryDelayMilliseconds": 1000,
|
"ScoreRetryDelayMilliseconds": 1000,
|
||||||
"MaxImageBytes": 10485760,
|
"MaxImageBytes": 10485760,
|
||||||
|
|||||||
Reference in New Issue
Block a user