Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/AiChatService.cs
T
glz bdaa6a0dc8 refactor: 统一业务异常处理,标准化结果码和错误响应
1.  新增并完善ResultCode枚举,补充标准HTTP状态码对应的业务状态码
2.  重构BusinessException,新增基于ResultCode的构造函数和ThrowIf扩展方法
3.  替换所有硬编码的HTTP状态码为统一的ResultCode枚举
4.  优化全局异常中间件,根据业务状态码映射对应HTTP状态码并规范化JSON响应
5.  修复OssImageHelper和AutoDotCodeConsumer中的OSS文件处理逻辑
6.  新增用户答题快照实体类
7.  清理废弃的宠物模块迁移脚本
2026-06-29 16:34:26 +08:00

174 lines
7.6 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 Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// AI聊天服务实现(OpenAI API规范,流式返回)
/// </summary>
public class AiChatService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<AiChatService> logger) : IAiChatService
{
/// <summary>
/// 预设系统提示词
/// </summary>
private const string SystemPrompt = """
# 提示词:AI 优化"题目评分/报告提示词"
你是一个专业的提示词优化专家。你的任务是**将用户输入的粗糙、口语化的"题目评分/报告提示词"重构为结构清晰、指令明确、便于 AI 直接执行的标准化提示词**
用户(题目制作者)当前输入的内容可能包含:题目描述、作答要求、打分规则、评分维度、报告层级、示例等,但组织混乱、表述模糊。你需要**保留所有原始信息,不增删任何实质性规则**,仅优化其结构、逻辑层次和表达精度。
---
## 优化原则
1. **信息完整**:用户原文中的所有要点、示例、层级区分必须全部保留。
2. **结构清晰**:将内容归入标准模块(如 `任务目标`、`输入格式`、`评分标准`、`报告生成要求`)。
3. **指令明确**:将模糊表述(如"要写得好一点")转化为可操作的判定条件(如"必须包含三层结构:解释原因 + 表达情绪 + 提出方案")。
4. **语言精练**:去除冗余修饰,保留关键限定词(如"仅""必须""至少")。
5. **格式统一**:最终输出为 Markdown 格式,使用标题(`#` `##` `###`)、列表(`-`)、加粗/斜体等提升可读性。
---
## 优化步骤
1. **解析原文**:通读用户输入,标注出:
- 题目背景 / 情境
- 学生作答要求
- 评分维度(优秀/一般/薄弱)及其具体判定条件
- 报告层级及其对应的评语模板或生成逻辑
- 任何示例答案
2. **重组结构**:按以下标准模板组织内容(可灵活调整章节名称):
- `## 任务概述`(题目 + 要求)
- `## 输入数据`(学生作答的内容)
- `## 评分标准`(分档描述,含判定规则)
- `## 报告生成规范`(层级名称、触发条件、评语示例/生成规则)
- `## 注意事项`(如有特殊约束)
3. **润色表达**
- "可能""大概"等不确定词改为明确的"必须""至少"
- 将并列条件用编号列表(1. 2. 3.)拆分;
- 将示例单独以代码块或引用块呈现,避免与规则混淆。
4. **保持原意**:不得添加用户未提及的新评分维度或报告层级,也不得删除任何已有规则。
---
## 输出格式要求
- 只输出优化后的完整提示词(即 AI 可直接使用的评分/报告 prompt),不要输出分析过程。
- 使用 Markdown 语法,标题层级从 `#` 开始。
- 所有原有示例必须原样保留,并标明"示例"或放在引用块中。
---
现在,请对**当前用户输入的原始提示词**执行上述优化过程,仅输出优化后的 Markdown 格式提示词。
""";
/// <summary>
/// 流式AI聊天,逐块返回AI生成的内容
/// </summary>
public async IAsyncEnumerable<string> ChatAsync(
AiChatInput input,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(input.Message))
{
throw new BusinessException("消息内容不能为空", ResultCode.BAD_REQUEST);
}
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 BusinessException("AI聊天服务配置不完整,请检查 AiChat 配置节", ResultCode.GLOBAL_ERROR);
}
logger.LogInformation("开始调用AI聊天服务(流式),消息长度:{Length}", input.Message.Length);
// 构建 OpenAI Chat Completions 请求体(启用流式返回)
var requestBody = new
{
model = model,
messages = new[]
{
new { role = "system", content = SystemPrompt },
new { role = "user", content = input.Message }
},
max_tokens = maxTokens,
temperature = temperature,
stream = true
};
var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
// 创建 HttpClient 并发起请求(使用 ResponseHeadersRead 提前获取响应头以流式读取)
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(timeoutSeconds > 0 ? timeoutSeconds : 300);
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogError("AI聊天服务调用失败,状态码:{StatusCode},响应:{Response}", response.StatusCode, errorContent);
throw new BusinessException($"AI服务调用失败:{response.StatusCode}", ResultCode.GLOBAL_ERROR);
}
// 流式读取响应体
using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(responseStream);
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken);
if (line == null) break;
// 只处理 data: 开头的行
if (!line.StartsWith("data: ")) continue;
var data = line.Substring(6);
if (data == "[DONE]") break;
// 解析 SSE chunk,提取 delta.content
using var chunkDoc = JsonDocument.Parse(data);
var choicesEl = chunkDoc.RootElement.GetProperty("choices");
if (choicesEl.GetArrayLength() == 0) continue;
var delta = choicesEl[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var contentEl))
{
var content = contentEl.GetString();
if (!string.IsNullOrEmpty(content))
{
yield return content;
}
}
}
logger.LogInformation("AI聊天服务(流式)调用完成");
}
}