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; /// /// AI聊天服务实现(OpenAI API规范,流式返回) /// public class AiChatService( IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) : IAiChatService { /// /// 预设系统提示词 /// private const string SystemPrompt = """ # 提示词:AI 优化"题目评分/报告提示词" 你是一个专业的提示词优化专家。你的任务是**将用户输入的粗糙、口语化的"题目评分/报告提示词"重构为结构清晰、指令明确、便于 AI 直接执行的标准化提示词**。 用户(题目制作者)当前输入的内容可能包含:题目描述、作答要求、打分规则、评分维度、报告层级、示例等,但组织混乱、表述模糊。你需要**保留所有原始信息,不增删任何实质性规则**,仅优化其结构、逻辑层次和表达精度。 --- ## 优化原则 1. **信息完整**:用户原文中的所有要点、示例、层级区分必须全部保留。 2. **结构清晰**:将内容归入标准模块(如 `任务目标`、`输入格式`、`评分标准`、`报告生成要求`)。 3. **指令明确**:将模糊表述(如"要写得好一点")转化为可操作的判定条件(如"必须包含三层结构:解释原因 + 表达情绪 + 提出方案")。 4. **语言精练**:去除冗余修饰,保留关键限定词(如"仅""必须""至少")。 5. **格式统一**:最终输出为 Markdown 格式,使用标题(`#` `##` `###`)、列表(`-`)、加粗/斜体等提升可读性。 --- ## 优化步骤 1. **解析原文**:通读用户输入,标注出: - 题目背景 / 情境 - 学生作答要求 - 评分维度(优秀/一般/薄弱)及其具体判定条件 - 报告层级及其对应的评语模板或生成逻辑 - 任何示例答案 2. **重组结构**:按以下标准模板组织内容(可灵活调整章节名称): - `## 任务概述`(题目 + 要求) - `## 输入数据`(学生作答的内容) - `## 评分标准`(分档描述,含判定规则) - `## 报告生成规范`(层级名称、触发条件、评语示例/生成规则) - `## 注意事项`(如有特殊约束) 3. **润色表达**: - 将"可能""大概"等不确定词改为明确的"必须""至少"; - 将并列条件用编号列表(1. 2. 3.)拆分; - 将示例单独以代码块或引用块呈现,避免与规则混淆。 4. **保持原意**:不得添加用户未提及的新评分维度或报告层级,也不得删除任何已有规则。 --- ## 输出格式要求 - 只输出优化后的完整提示词(即 AI 可直接使用的评分/报告 prompt),不要输出分析过程。 - 使用 Markdown 语法,标题层级从 `#` 开始。 - 所有原有示例必须原样保留,并标明"示例"或放在引用块中。 --- 现在,请对**当前用户输入的原始提示词**执行上述优化过程,仅输出优化后的 Markdown 格式提示词。 """; /// /// 流式AI聊天,逐块返回AI生成的内容 /// public async IAsyncEnumerable ChatAsync( AiChatInput input, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(input.Message)) { throw new BusinessException("消息内容不能为空", 400); } var apiKey = configuration["AiChat:ApiKey"]; var baseUrl = configuration["AiChat:BaseUrl"]; var model = configuration["AiChat:Model"]; var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); var maxTokens = configuration.GetValue("AiChat:MaxTokens"); var temperature = configuration.GetValue("AiChat:Temperature"); if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) { throw new BusinessException("AI聊天服务配置不完整,请检查 AiChat 配置节", 500); } 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}", 500); } // 流式读取响应体 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聊天服务(流式)调用完成"); } }