feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整
- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用 - 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方 - 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧 - 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影 - 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整 - 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
This commit is contained in:
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||
void Delete(Conversation conversation);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||
userId,
|
||||
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||
ct);
|
||||
var conversations = await _conversations.ListAsync(userId, ct);
|
||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record DietCommentaryFood(string Name, string Portion, int Calories);
|
||||
|
||||
public static class DietCommentaryPolicy
|
||||
{
|
||||
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||
|
||||
public const string SystemPrompt = """
|
||||
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||
""";
|
||||
|
||||
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||
string.Join("、", foods.Select(food =>
|
||||
$"{Normalize(food.Name, 40)}({Normalize(food.Portion, 30)},{Math.Clamp(food.Calories, 0, 10000)}千卡)"));
|
||||
|
||||
public static bool IsLegacyArtifact(IEnumerable<ConversationMessage> messages)
|
||||
{
|
||||
var userMessages = messages.Where(message => message.Role == MessageRole.User).ToList();
|
||||
return userMessages.Count > 0 &&
|
||||
userMessages.All(message => message.Content.TrimStart().StartsWith(LegacyPromptPrefix, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string Normalize(string value, int maxLength)
|
||||
{
|
||||
var normalized = string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
return normalized.Length > maxLength ? normalized[..maxLength] : normalized;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
||||
{
|
||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
||||
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m,
|
||||
HealthMetricType.SpO2 => request.Value <= 94,
|
||||
HealthMetricType.Weight => false,
|
||||
_ => false
|
||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ public sealed class HealthRecordService(
|
||||
latest.Diastolic,
|
||||
latest.Value,
|
||||
latest.Unit,
|
||||
latest.IsAbnormal,
|
||||
latest.RecordedAt
|
||||
};
|
||||
}
|
||||
@@ -140,7 +141,7 @@ public sealed class HealthRecordService(
|
||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||
HealthMetricType.HeartRate =>
|
||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
||||
HealthMetricType.Glucose when record.Value > 6.1m =>
|
||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||
HealthMetricType.Glucose =>
|
||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||
|
||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
||||
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
||||
await _db.ConversationMessages.AddAsync(message, ct);
|
||||
|
||||
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
|
||||
Guid userId,
|
||||
string promptPrefix,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = await _db.Conversations
|
||||
.Include(conversation => conversation.Messages)
|
||||
.Where(conversation =>
|
||||
conversation.UserId == userId &&
|
||||
conversation.Messages.Any(message =>
|
||||
message.Role == MessageRole.User &&
|
||||
message.Content.StartsWith(promptPrefix)))
|
||||
.ToListAsync(ct);
|
||||
var artifacts = candidates
|
||||
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
|
||||
.ToList();
|
||||
if (artifacts.Count == 0) return 0;
|
||||
|
||||
_db.Conversations.RemoveRange(artifacts);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return artifacts.Count;
|
||||
}
|
||||
|
||||
public void Delete(Conversation conversation) =>
|
||||
_db.Conversations.Remove(conversation);
|
||||
|
||||
|
||||
@@ -335,6 +335,49 @@ public static class AiChatEndpoints
|
||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||
app.MapPost("/api/ai/diet-commentary", async (
|
||||
DietCommentaryRequest request,
|
||||
HttpContext http,
|
||||
DeepSeekClient llmClient,
|
||||
IPatientContextService patientContexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == null)
|
||||
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||
|
||||
var foods = request.Foods?
|
||||
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
|
||||
!string.IsNullOrWhiteSpace(food.Portion) &&
|
||||
food.Calories > 0)
|
||||
.Take(20)
|
||||
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
|
||||
.ToList() ?? [];
|
||||
if (foods.Count == 0)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
|
||||
|
||||
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
|
||||
var response = await llmClient.ChatAsync(
|
||||
[
|
||||
new ChatMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
|
||||
},
|
||||
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
|
||||
],
|
||||
maxTokens: 300,
|
||||
temperature: 0.3f,
|
||||
ct: ct);
|
||||
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(commentary))
|
||||
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/ai/analyze-food-image", async (
|
||||
HttpRequest httpRequest,
|
||||
HttpContext http,
|
||||
@@ -623,7 +666,7 @@ public static class AiChatEndpoints
|
||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||
break;
|
||||
case "glucose":
|
||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m);
|
||||
break;
|
||||
case "spo2":
|
||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||
@@ -818,3 +861,6 @@ public static class AiChatEndpoints
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||
|
||||
@@ -223,6 +223,7 @@ public sealed class ApplicationServiceTests
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||
public void Delete(Conversation value) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class DietCommentaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void FoodDescription_ContainsOnlyStructuredFoodData()
|
||||
{
|
||||
var result = DietCommentaryPolicy.BuildFoodDescription(
|
||||
[
|
||||
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
|
||||
new DietCommentaryFood("米饭", "一碗", 260),
|
||||
]);
|
||||
|
||||
Assert.Equal("番茄炒蛋(半盘,220千卡)、米饭(一碗,260千卡)", result);
|
||||
Assert.DoesNotContain("请结合", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
|
||||
{
|
||||
var artifact = new Conversation
|
||||
{
|
||||
Messages =
|
||||
[
|
||||
new ConversationMessage
|
||||
{
|
||||
Role = MessageRole.User,
|
||||
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||
},
|
||||
new ConversationMessage
|
||||
{
|
||||
Role = MessageRole.Assistant,
|
||||
Content = "注意搭配蔬菜",
|
||||
},
|
||||
],
|
||||
};
|
||||
var normal = new Conversation
|
||||
{
|
||||
Messages =
|
||||
[
|
||||
new ConversationMessage
|
||||
{
|
||||
Role = MessageRole.User,
|
||||
Content = "我想了解今天的饮食记录",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
|
||||
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Application.AI;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI;
|
||||
@@ -13,6 +14,50 @@ namespace Health.Tests;
|
||||
|
||||
public sealed class PersistencePipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var artifact = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = AgentType.Default,
|
||||
};
|
||||
artifact.Messages.Add(new ConversationMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = artifact.Id,
|
||||
Role = MessageRole.User,
|
||||
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||
});
|
||||
var normal = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = AgentType.Default,
|
||||
};
|
||||
normal.Messages.Add(new ConversationMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = normal.Id,
|
||||
Role = MessageRole.User,
|
||||
Content = "请分析我今天吃的米饭",
|
||||
});
|
||||
db.Conversations.AddRange(artifact, normal);
|
||||
await db.SaveChangesAsync();
|
||||
var repository = new EfAiConversationRepository(db);
|
||||
|
||||
var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||
userId,
|
||||
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user