feat: 新增AI自动批改功能,完善任务评分体系
1. 添加AI评分配置节点到appsettings.json 2. 新增NeedAiProcess字段控制是否使用AI批改 3. 扩展答题记录实体,新增理解力、判断力、表达力、说服力评分字段 4. 实现期刊任务AI自动批改消费者逻辑,支持重试机制和图片处理 5. 优化实体类注释和代码结构 6. 新增Dockerfile用于容器化部署
This commit is contained in:
@ -29,5 +29,10 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
|
||||
/// </summary>
|
||||
public TaskBankTypeEnum Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要AI批改,默认需要
|
||||
/// </summary>
|
||||
public bool NeedAiProcess { get; set; } = true;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +74,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
|
||||
/// Prompt配置
|
||||
/// </summary>
|
||||
public string Prompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要AI批改
|
||||
/// </summary>
|
||||
public bool NeedAiProcess { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -71,6 +71,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal
|
||||
/// </summary>
|
||||
public string Prompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要AI批改,false时由人工批改
|
||||
/// </summary>
|
||||
public bool NeedAiProcess { get; set; } = true;
|
||||
|
||||
}
|
||||
|
||||
public class JournalPageTaskComplementInput
|
||||
|
||||
@ -2,9 +2,9 @@ using SqlSugar;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Models.Entity
|
||||
{
|
||||
///<summary>
|
||||
///杂志任务用户作答记录;
|
||||
///</summary>
|
||||
/// <summary>
|
||||
/// 杂志任务用户作答记录
|
||||
/// </summary>
|
||||
[SugarTable("JournalPageTaskUserAnswer")]
|
||||
public class JournalPageTaskUserAnswer : SqlSugarBaseEntity
|
||||
{
|
||||
@ -12,204 +12,231 @@ namespace QYZH.InteractiveMagazine.Models.Entity
|
||||
/// Desc:书id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long JournalId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:书页id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long JournalPageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:任务id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long JournalPageTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:任务分组id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long JournalPageTaskGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:用户id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作答结果
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:积分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
public float Points { get; set; }
|
||||
/// </summary>
|
||||
public int Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:成长值
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public float GrowthPoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:题目得分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public float Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:理解力评分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
public float Comprehension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:判断力评分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
public float Judgment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:表达力评分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
public float Expression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:说服力评分
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
public float Persuasiveness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:题目作答图片地址
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string QuestionAnswerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:答案图片地址
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string AnswerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:页面作答图片地址
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string PageAnswerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:乐观锁
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int Revision { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作答状态
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int AnswerStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作答开始时间
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public DateTime AnswerStartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作答结束时间
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public DateTime AnswerEndTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作答耗时(秒)
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int AnswerSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:图像识别结果
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int ImageRecognition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:书页序号
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int JournalPageNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:修改次数
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int Modify { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:最后标签id
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public long LastTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:点读书页序号
|
||||
/// Default:
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int DotPageNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:页面结果图片地址
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string PageResultUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:题型
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:点读书页编号
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string DotPageNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:页面作答点读图片地址
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string PageAnswerDotUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:中断次数
|
||||
/// Default:0
|
||||
/// Nullable:False
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public int BreakCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:中断时间记录
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string BreakTimes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Desc:作业状态
|
||||
/// Default:
|
||||
/// Nullable:True
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
public string AssignmentStatus { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ namespace QYZH.InteractiveMagazine.Models.Entity
|
||||
/// <summary>
|
||||
/// Points awarded.
|
||||
/// </summary>
|
||||
public float Points { get; set; }
|
||||
public int Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Growth points awarded.
|
||||
@ -58,6 +58,26 @@ namespace QYZH.InteractiveMagazine.Models.Entity
|
||||
/// </summary>
|
||||
public float Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comprehension score.
|
||||
/// </summary>
|
||||
public float Comprehension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Judgment score.
|
||||
/// </summary>
|
||||
public float Judgment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expression score.
|
||||
/// </summary>
|
||||
public float Expression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Persuasiveness score.
|
||||
/// </summary>
|
||||
public float Persuasiveness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Question answer image URL.
|
||||
/// </summary>
|
||||
|
||||
@ -24,6 +24,7 @@ public class JournalPageTaskService(BaseRepository<Journal> journalRepository, O
|
||||
var map = input.Adapt<JournalPageTask>();
|
||||
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<Journal> 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<Journal> 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<Journal> journalRepository, O
|
||||
Expression = task.Expression,
|
||||
Persuasiveness = task.Persuasiveness,
|
||||
AnswerTime = task.AnswerTime,
|
||||
Prompt = task.Prompt
|
||||
Prompt = task.Prompt,
|
||||
NeedAiProcess = task.NeedAiProcess
|
||||
};
|
||||
|
||||
// 查询答案列表
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 期刊任务接收消费者(示例)
|
||||
/// 期刊任务接收消费者
|
||||
/// </summary>
|
||||
public class JournalTaskReceiveConsumer(ILogger<JournalTaskReceiveConsumer> logger, IConfiguration configuration,
|
||||
public class JournalTaskReceiveConsumer(
|
||||
ILogger<JournalTaskReceiveConsumer> 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<JournalTaskReceiveConsumer> logg
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
|
||||
// TODO: 在此编写具体的消息处理逻辑
|
||||
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
|
||||
{
|
||||
var data = System.Text.Json.JsonSerializer.Deserialize<QuestionData>(message);
|
||||
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 (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<JournalTaskReceiveConsumer> logg
|
||||
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 ?? 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<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,
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
public string PageAnswerUrl { get; set; }
|
||||
public Question[] Questions { 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; }
|
||||
public string Url { get; set; }
|
||||
public string[] AnswerUrl { 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; }
|
||||
public List<BreakTime> BreakTimes { 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);
|
||||
|
||||
16
QYZH.InteractiveMagazine.WorkService/Dockerfile
Normal file
16
QYZH.InteractiveMagazine.WorkService/Dockerfile
Normal file
@ -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"]
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user