using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Health.Domain.Entities; using Health.Domain.Enums; using Health.Infrastructure.AI; using Health.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace Health.Tests; /// /// AI 智能体集成测试 — 模拟真实用户对话,验证 Tool Calling 与数据库写入 /// 运行前需确保后端已启动: dotnet run --project src/Health.WebApi /// public class AiAgentTests { private static readonly HttpClient Http = new() { BaseAddress = new Uri("http://localhost:5000"), Timeout = TimeSpan.FromSeconds(120) }; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; // ==================== PromptManager 单元测试 ==================== [Fact] public void PromptManager_Default_Should_Contain_HeartKeywords() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Default); Assert.Contains("心脏", prompt); Assert.Contains("健康", prompt); } [Fact] public void PromptManager_Consultation_Should_Contain_TriageRules() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Consultation); Assert.Contains("剧烈胸痛", prompt); Assert.Contains("呼吸困难", prompt); Assert.Contains("160/100", prompt); } [Fact] public void PromptManager_Health_Should_Contain_NormalRanges() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Health); Assert.Contains("139", prompt); // 收缩压上界 Assert.Contains("89", prompt); // 舒张压下界 Assert.Contains("100", prompt); // 心率上界 } [Fact] public void PromptManager_Diet_Should_Contain_VlmKeywords() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Diet); Assert.Contains("VLM", prompt); Assert.Contains("能不能吃", prompt); } [Fact] public void PromptManager_Medication_Should_Contain_ParseRules() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Medication); Assert.Contains("药名", prompt); Assert.Contains("剂量", prompt); } [Fact] public void PromptManager_Report_Should_Contain_Disclaimer() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Report); Assert.Contains("AI预解读", prompt); Assert.Contains("医生确认", prompt); } [Fact] public void PromptManager_Exercise_Should_Contain_RehabKeywords() { var pm = new PromptManager(); var prompt = pm.GetSystemPrompt(AgentType.Exercise); Assert.Contains("心脏康复", prompt); Assert.Contains("循序渐进", prompt); } [Fact] public void PromptManager_All_Agents_Should_Return_NonEmpty() { var pm = new PromptManager(); foreach (AgentType agent in Enum.GetValues()) { var prompt = pm.GetSystemPrompt(agent); Assert.False(string.IsNullOrWhiteSpace(prompt), $"{agent} 的 SystemPrompt 为空"); } } // ==================== DeepSeekClient 连通性测试 ==================== [Fact] public void DeepSeekClient_ApiKey_Should_Be_Configured() { // 仅验证 API Key 已配置(实际调用通过集成测试验证) CreateDeepSeekClient(); var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); var apiKey = config["DEEPSEEK_API_KEY"] ?? ""; Assert.False(string.IsNullOrEmpty(apiKey) || apiKey.StartsWith("sk-your-key"), "DEEPSEEK_API_KEY 未配置或为占位符,请在 backend/.env 中设置真实 Key"); } // ==================== AI 对话 + Tool Calling 集成测试 ==================== [Fact] public async Task HealthAgent_RecordBloodPressure_Should_SaveToDb() { // 先登录获取 token var token = await LoginAsync("13800000001"); // 发送对话消息触发 Tool Calling var events = await SendChatMessage(token, "health", "我刚刚测了血压,138/86"); var toolResults = events.Where(e => e.Action == "tool_result").ToList(); Assert.NotEmpty(toolResults); } [Fact] public async Task HealthAgent_RecordHeartRate_Should_SaveToDb() { var token = await LoginAsync("13800000001"); var events = await SendChatMessage(token, "health", "心率72"); var toolResults = events.Where(e => e.Action == "tool_result").ToList(); Assert.NotEmpty(toolResults); } [Fact] public async Task MedicationAgent_Query_Should_Return_Medications() { var token = await LoginAsync("13800000001"); var events = await SendChatMessage(token, "medication", "我现在在吃什么药?"); var answers = events.Where(e => e.Action == "answer") .Select(e => e.Data?.ToString() ?? ""); var fullResponse = string.Join("", answers); Assert.NotEmpty(fullResponse); // 应该提到阿司匹林或阿托伐他汀 Assert.True(fullResponse.Contains("阿司匹林") || fullResponse.Contains("阿托伐他汀") || fullResponse.Contains("Aspirin")); } [Fact] public async Task ConsultationAgent_SymptomCheck_Should_AskFollowUp() { var token = await LoginAsync("13800000001"); var events = await SendChatMessage(token, "consultation", "最近胸口有点不舒服"); var answers = events.Where(e => e.Action == "answer") .Select(e => e.Data?.ToString() ?? ""); var fullResponse = string.Join("", answers); Assert.NotEmpty(fullResponse); } [Fact] public async Task DefaultAgent_GeneralQuestion_Should_Respond() { var token = await LoginAsync("13800000001"); var events = await SendChatMessage(token, "default", "你好,介绍一下你自己"); var answers = events.Where(e => e.Action == "answer") .Select(e => e.Data?.ToString() ?? ""); var fullResponse = string.Join("", answers); Assert.NotEmpty(fullResponse); Assert.True(fullResponse.Length > 10, "默认 Agent 应返回有效回复"); } // ==================== 辅助方法 ==================== /// /// 发送验证码 + 登录,返回 accessToken /// private static async Task LoginAsync(string phone) { // 发送验证码 var smsPayload = JsonSerializer.Serialize(new { phone }, JsonOpts); var smsResp = await Http.PostAsync("/api/auth/send-sms", new StringContent(smsPayload, Encoding.UTF8, "application/json")); var smsJson = JsonDocument.Parse(await smsResp.Content.ReadAsStringAsync()); var devCode = smsJson.RootElement.GetProperty("data").GetProperty("devCode").GetString()!; // 登录 var loginPayload = JsonSerializer.Serialize(new { phone, smsCode = devCode }, JsonOpts); var loginResp = await Http.PostAsync("/api/auth/login", new StringContent(loginPayload, Encoding.UTF8, "application/json")); var loginJson = JsonDocument.Parse(await loginResp.Content.ReadAsStringAsync()); return loginJson.RootElement.GetProperty("data").GetProperty("accessToken").GetString()!; } /// /// 向指定 Agent 发送消息,返回所有 SSE 事件 /// private static async Task> SendChatMessage(string token, string agentType, string message) { var url = $"/api/ai/{agentType}/chat?message={Uri.EscapeDataString(message)}&token={Uri.EscapeDataString(token)}"; Http.DefaultRequestHeaders.Authorization = null; // token 走 query string var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); var events = new List(); using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); string? line; while ((line = await reader.ReadLineAsync()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; if (!line.StartsWith("data: ")) continue; var data = line["data: ".Length..]; if (data == "[DONE]") break; try { var parsed = JsonSerializer.Deserialize(data, JsonOpts); if (parsed != null) events.Add(parsed); } catch { /* 跳过无法解析的 chunk */ } } return events; } /// /// 创建 DeepSeekClient(读取 .env 配置) /// private static DeepSeekClient CreateDeepSeekClient() { // 从测试输出目录向上 5 级找到 backend/.env // bin/Debug/net10.0 → Health.Tests → tests → backend var baseDir = AppContext.BaseDirectory; var envPath = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", ".env")); if (File.Exists(envPath)) { foreach (var envLine in File.ReadAllLines(envPath)) { var trimmed = envLine.Trim(); if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue; var eqIdx = trimmed.IndexOf('='); if (eqIdx <= 0) continue; var key = trimmed[..eqIdx].Trim(); var value = trimmed[(eqIdx + 1)..].Trim(); Environment.SetEnvironmentVariable(key, value); } } var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); var apiKey = config["DEEPSEEK_API_KEY"] ?? ""; var baseUrl = (config["DEEPSEEK_BASE_URL"] ?? "https://api.deepseek.com/v1").TrimEnd('/') + "/"; var httpClient = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(120) }; httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); return new DeepSeekClient(httpClient, config); } } /// SSE 事件模型 public class SseEvent { public string? Action { get; set; } public object? Data { get; set; } public string? Message { get; set; } }