Initial commit: 健康管家 AI 健康陪伴助手
- Backend: .NET 10 Minimal API + EF Core + PostgreSQL - Frontend: Flutter + Riverpod + GoRouter + Dio - AI: DeepSeek LLM + Qwen VLM (OpenAI-compatible) - Auth: SMS + JWT (access/refresh tokens) - Features: AI chat, health tracking, medication management, diet analysis, exercise plans, doctor consultations, report analysis
This commit is contained in:
152
backend/src/Health.Infrastructure/AI/AiClients.cs
Normal file
152
backend/src/Health.Infrastructure/AI/AiClients.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// DeepSeek LLM 客户端(对话 + Tool Calling)
|
||||
/// </summary>
|
||||
public sealed class DeepSeekClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _model;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
_http = http;
|
||||
_model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
|
||||
_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 流式 Chat Completions
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<string> ChatStreamAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = true,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions") { Content = content };
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
if (!line.StartsWith("data: ")) continue;
|
||||
var data = line["data: ".Length..];
|
||||
if (data == "[DONE]") break;
|
||||
yield return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 非流式 Chat Completions(用于 Tool Calling)
|
||||
/// </summary>
|
||||
public async Task<ChatCompletionResponse> ChatAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = false,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 千问 VL 视觉客户端(食物识别 + 报告解读)
|
||||
/// </summary>
|
||||
public sealed class QwenVisionClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _model;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public QwenVisionClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
_http = http;
|
||||
_model = config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
|
||||
_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ChatCompletionResponse> VisionAsync(
|
||||
string systemPrompt,
|
||||
List<string> imageUrls,
|
||||
string? userText = null,
|
||||
int maxTokens = 2048,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
if (!string.IsNullOrEmpty(systemPrompt))
|
||||
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
||||
|
||||
var contentParts = new List<object>();
|
||||
foreach (var url in imageUrls)
|
||||
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
||||
if (!string.IsNullOrEmpty(userText))
|
||||
contentParts.Add(new { type = "text", text = userText });
|
||||
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
|
||||
};
|
||||
messages.Add(userMessage);
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
235
backend/src/Health.Infrastructure/AI/OpenAiCompatibleClient.cs
Normal file
235
backend/src/Health.Infrastructure/AI/OpenAiCompatibleClient.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
|
||||
/// </summary>
|
||||
public sealed class OpenAiCompatibleClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _model;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
|
||||
{
|
||||
_http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_model = model;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 流式 Chat Completions(SSE)
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<string> ChatStreamAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = true,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
};
|
||||
|
||||
if (tools?.Count > 0)
|
||||
request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
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;
|
||||
|
||||
yield return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 非流式 Chat Completions
|
||||
/// </summary>
|
||||
public async Task<ChatCompletionResponse> ChatAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = false,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
};
|
||||
|
||||
if (tools?.Count > 0)
|
||||
request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _http.PostAsync("chat/completions", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vision 图片理解(非流式)
|
||||
/// </summary>
|
||||
public async Task<ChatCompletionResponse> VisionAsync(
|
||||
string systemPrompt,
|
||||
List<string> imageUrls,
|
||||
string? userText = null,
|
||||
int maxTokens = 2048)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (!string.IsNullOrEmpty(systemPrompt))
|
||||
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
||||
|
||||
// 构建多模态消息内容
|
||||
var contentParts = new List<object>();
|
||||
foreach (var url in imageUrls)
|
||||
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
||||
if (!string.IsNullOrEmpty(userText))
|
||||
contentParts.Add(new { type = "text", text = userText });
|
||||
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
|
||||
};
|
||||
|
||||
messages.Add(userMessage);
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = maxTokens,
|
||||
Stream = false,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync("chat/completions", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
#region 请求/响应模型
|
||||
|
||||
public sealed class ChatCompletionRequest
|
||||
{
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public List<ChatMessage> Messages { get; set; } = [];
|
||||
public bool Stream { get; set; }
|
||||
public int MaxTokens { get; set; } = 2048;
|
||||
public float Temperature { get; set; } = 0.7f;
|
||||
public List<ToolDefinition>? Tools { get; set; }
|
||||
public string? ToolChoice { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string? ToolCallId { get; set; }
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ToolDefinition
|
||||
{
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public object Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCall
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolCallFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCallFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Arguments { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ChatCompletionResponse
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public List<Choice> Choices { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class Choice
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public ResponseMessage? Message { get; set; }
|
||||
public ResponseDelta? Delta { get; set; }
|
||||
public string? FinishReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string? Content { get; set; }
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseDelta
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
116
backend/src/Health.Infrastructure/AI/PromptManager.cs
Normal file
116
backend/src/Health.Infrastructure/AI/PromptManager.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一个心脏术后康复患者的私人 AI 健康管家,名叫"阿福"。
|
||||
语气温暖、专业、像朋友一样关怀患者。
|
||||
|
||||
职责:
|
||||
1. 理解用户的健康需求,解析健康数据
|
||||
2. 主动查看患者近期数据,发现异常时提醒
|
||||
3. 回答健康知识问题
|
||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||
|
||||
规则:
|
||||
- 不要提供超出你能力范围的医疗建议
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||
""";
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
|
||||
|
||||
规则:
|
||||
1. 每次只问一个问题,不要一次问多个
|
||||
2. 给出 2-3 个快捷选项让患者点击
|
||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
|
||||
6. 所有分析末尾标注"以上为AI分析,具体请咨询医生"
|
||||
7. 问诊结束给出结构化小结
|
||||
""";
|
||||
|
||||
private const string HealthDataPrompt = """
|
||||
你是一个健康数据录入助手。
|
||||
|
||||
规则:
|
||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||
2. 指标明确+数值明确→直接录入
|
||||
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||
4. 时间模糊→取当前时间,在确认卡片中告知用户
|
||||
5. 数值超出正常范围→附带异常提醒
|
||||
6. 录入后生成确认卡片格式的回复
|
||||
|
||||
正常值参考范围:
|
||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||
- 心率 60-100 次/分
|
||||
- 空腹血糖 3.9-6.1 mmol/L
|
||||
- 血氧 95-100%
|
||||
""";
|
||||
|
||||
private const string DietPrompt = """
|
||||
你是一个营养分析专家,专门为心脏术后患者提供饮食指导。
|
||||
|
||||
规则:
|
||||
1. 收到VLM食物识别结果后,结合患者档案进行综合分析
|
||||
2. 总热量汇总
|
||||
3. 逐项判断"能不能吃"(基于疾病诊断/过敏/饮食限制/近期指标)
|
||||
4. 给出 1-5 星健康评分
|
||||
5. 单项警告 + 整体饮食建议
|
||||
6. 追问餐次归属(早餐/午餐/晚餐/加餐)
|
||||
""";
|
||||
|
||||
private const string MedicationPrompt = """
|
||||
你是一个用药管理专家。
|
||||
|
||||
规则:
|
||||
1. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||
2. "早饭后"等模糊时间→追问具体几点
|
||||
3. 解析完成后展示确认卡片
|
||||
4. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||
5. 回答用药相关疑问
|
||||
""";
|
||||
|
||||
private const string ReportPrompt = """
|
||||
你是一个医学报告解读专家。
|
||||
|
||||
规则:
|
||||
1. 收到报告图片后,提取所有指标及其数值
|
||||
2. 标注异常指标(偏高/偏低/正常)
|
||||
3. 给出初步分析
|
||||
4. 所有内容标注"AI预解读,待医生确认"
|
||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
|
||||
private const string ExercisePrompt = """
|
||||
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
||||
|
||||
规则:
|
||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||
2. 以周为单位,每天指定运动类型和时长
|
||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
""";
|
||||
}
|
||||
Reference in New Issue
Block a user