Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs

819 lines
29 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 SqlSugar;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
/// <summary>
/// 期刊任务接收消费者
/// </summary>
public class JournalTaskReceiveConsumer(
ILogger<JournalTaskReceiveConsumer> logger,
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
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";
public string QueueName => "mq.journal.task.receive";
public string RoutingKey => "rk.journal.task.receive";
public async Task HandleAsync(byte[] body, CancellationToken cancellationToken = default)
{
var message = Encoding.UTF8.GetString(body);
logger.LogInformation("收到期刊任务消息: {Message}", message);
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
var data = JsonSerializer.Deserialize<QuestionData>(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<JournalPageTask>()
.Where(t => taskIds.Contains(t.Id) && !t.IsDeleted)
.ToListAsync(cancellationToken);
var taskMap = tasks.ToDictionary(t => t.Id);
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
.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<JournalPage>()
.Where(p => p.Id == data.PageId && !p.IsDeleted)
.FirstAsync(cancellationToken);
var answerEntities = new List<JournalPageTaskUserAnswer>();
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
{
foreach (var answer in answerEntities)
{
var existing = await client.Queryable<JournalPageTaskUserAnswer>()
.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
{
client.Ado.RollbackTran();
throw;
}
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)
{
var message = Encoding.UTF8.GetString(body);
logger.LogError(exception, "处理期刊任务消息失败: {Message}", message);
return Task.CompletedTask;
}
private async Task<JournalAnswerScoreResult> ScoreQuestionAsync(
JournalPageTask task,
Question question,
List<JournalPageTaskAnswer> referenceAnswers,
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 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>
{
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<JournalPageTaskAnswer> 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}");
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<List<AnswerImageContent>> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken)
{
var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? [];
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
foreach (var imageUrl in imageUrls)
{
cancellationToken.ThrowIfCancellationRequested();
await using var imageStream = 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<List<AnswerImageContent>> BuildReferenceAnswerImageContentsAsync(
long taskId,
List<JournalPageTaskAnswer> referenceAnswers,
CancellationToken cancellationToken)
{
var imageUrls = referenceAnswers
.Select(a => a.AnswerUrL)
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct()
.Select(url => url!)
.ToList();
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
foreach (var imageUrl in imageUrls)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var imageStream = 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<string> 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)
{
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<JournalAnswerScoreResult>(cleanedJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return result ?? throw new InvalidOperationException("AI评分结果解析失败");
}
private static string CleanJsonContent(string content)
{
var text = content.Trim();
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstLineEnd = text.IndexOf('\n');
if (firstLineEnd >= 0)
{
text = text[(firstLineEnd + 1)..];
}
var fenceIndex = text.LastIndexOf("```", StringComparison.Ordinal);
if (fenceIndex >= 0)
{
text = text[..fenceIndex];
}
}
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start >= 0 && end > start)
{
text = text[start..(end + 1)];
}
return text.Trim();
}
private static 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,
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,
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];
}
}
/// <summary>
/// 期刊题目作答消息
/// </summary>
public class QuestionData
{
/// <summary>
/// 用户ID
/// </summary>
public long UserId { get; set; }
/// <summary>
/// 期刊ID
/// </summary>
public long JournalId { get; set; }
/// <summary>
/// 页ID
/// </summary>
public long PageId { get; set; }
/// <summary>
/// 页面作答图片地址
/// </summary>
public string PageAnswerUrl { get; set; } = string.Empty;
/// <summary>
/// 题目作答列表
/// </summary>
public Question[] Questions { get; set; } = [];
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
}
/// <summary>
/// 题目作答数据
/// </summary>
public class Question
{
/// <summary>
/// 题目ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 题目图片地址
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 答案图片地址
/// </summary>
public string[] AnswerUrl { get; set; } = [];
/// <summary>
/// 作答开始时间
/// </summary>
public DateTime AnswerStartTime { get; set; }
/// <summary>
/// 作答结束时间
/// </summary>
public DateTime AnswerEndTime { get; set; }
/// <summary>
/// 作答耗时秒数
/// </summary>
public int AnswerTime { get; set; }
/// <summary>
/// 中断次数
/// </summary>
public int BreakCount { get; set; }
/// <summary>
/// 中断记录
/// </summary>
public List<BreakTime> BreakTimes { get; set; } = [];
}
/// <summary>
/// 中断时间记录
/// </summary>
public class BreakTime
{
/// <summary>
/// 中断时间
/// </summary>
public DateTime Time { get; set; }
/// <summary>
/// 等待时间
/// </summary>
public long WaitTime { get; set; }
}
/// <summary>
/// AI评分结果
/// </summary>
public class JournalAnswerScoreResult
{
/// <summary>
/// 题目得分
/// </summary>
public float Score { get; set; }
/// <summary>
/// 成长值
/// </summary>
public int GrowthPoint { get; set; }
/// <summary>
/// 积分
/// </summary>
public int Points { get; set; }
/// <summary>
/// 理解力评分
/// </summary>
public float Comprehension { get; set; }
/// <summary>
/// 判断力评分
/// </summary>
public float Judgment { get; set; }
/// <summary>
/// 表达力评分
/// </summary>
public float Expression { get; set; }
/// <summary>
/// 说服力评分
/// </summary>
public float Persuasiveness { get; set; }
/// <summary>
/// 50字内评语
/// </summary>
public string Result { get; set; } = string.Empty;
}
public record AnswerImageContent(string DataUrl);