378 lines
15 KiB
C#
378 lines
15 KiB
C#
using System.Text.Json;
|
|
using MiaoJiZhang.Api.Contracts;
|
|
using MiaoJiZhang.Domain.Enums;
|
|
using MiaoJiZhang.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MiaoJiZhang.Api.Services;
|
|
|
|
public sealed class BudgetRecommendationService(
|
|
AppDbContext db,
|
|
ILlmClient llm,
|
|
ILogger<BudgetRecommendationService> logger)
|
|
{
|
|
private static readonly AgentToolDefinition AdjustBudgetTool = new(
|
|
"adjust_budget_draft",
|
|
"根据用户追加指令调整当前预算草稿。必须返回完整预算草稿,不能直接写入数据库。",
|
|
JsonSerializer.Deserialize<JsonElement>(
|
|
"""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"suggestedTotal": {
|
|
"type": "number",
|
|
"minimum": 0
|
|
},
|
|
"items": {
|
|
"type": "array",
|
|
"maxItems": 50,
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"categoryId": { "type": "integer" },
|
|
"amount": { "type": "number", "minimum": 0 }
|
|
},
|
|
"required": ["categoryId", "amount"],
|
|
"additionalProperties": false
|
|
}
|
|
},
|
|
"summary": {
|
|
"type": "string",
|
|
"description": "一句话说明本次调整了什么"
|
|
}
|
|
},
|
|
"required": ["suggestedTotal", "items", "summary"],
|
|
"additionalProperties": false
|
|
}
|
|
"""));
|
|
|
|
public async Task<BudgetRecommendationResponse> RefineAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
RefineBudgetRecommendationRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
if (!llm.IsEnabled)
|
|
throw new InvalidOperationException("AI 预算调整尚未配置");
|
|
|
|
var instruction = request.Instruction.Trim();
|
|
if (instruction.Length == 0)
|
|
throw new ArgumentException("请输入预算调整要求");
|
|
if (instruction.Length > 500)
|
|
throw new ArgumentException("单次调整要求不能超过 500 个字符");
|
|
if (request.CurrentDraft.Items.Count > 50)
|
|
throw new ArgumentException("预算分类数量过多");
|
|
|
|
var contexts = await LoadCategoryContextsAsync(
|
|
userId,
|
|
ledgerId,
|
|
request.Year,
|
|
request.Month,
|
|
ct);
|
|
var (currentStart, currentEnd) =
|
|
ChinaClock.MonthRangeUtc(request.Year, request.Month);
|
|
var currentSpentTotal = await db.Transactions
|
|
.Where(transaction =>
|
|
transaction.UserId == userId &&
|
|
transaction.LedgerId == ledgerId &&
|
|
transaction.Type == TransactionType.Expense &&
|
|
transaction.OccurredAt >= currentStart &&
|
|
transaction.OccurredAt < currentEnd)
|
|
.SumAsync(transaction => transaction.Amount, ct);
|
|
if (contexts.Count == 0)
|
|
throw new ArgumentException("没有可用于预算的支出分类");
|
|
|
|
var currentItems = request.CurrentDraft.Items
|
|
.Where(item => contexts.ContainsKey(item.CategoryId))
|
|
.GroupBy(item => item.CategoryId)
|
|
.ToDictionary(group => group.Key, group => Math.Max(0, group.Last().Amount));
|
|
|
|
BudgetRecommendationResponse? validated = null;
|
|
string? toolSummary = null;
|
|
var response = await llm.RunAgentAsync(
|
|
BuildSystemPrompt(request, contexts, currentItems, currentSpentTotal),
|
|
instruction,
|
|
[AdjustBudgetTool],
|
|
(call, _) =>
|
|
{
|
|
if (call.Name != AdjustBudgetTool.Name)
|
|
return Task.FromResult(JsonSerializer.Serialize(new
|
|
{
|
|
ok = false,
|
|
error = "未知预算工具",
|
|
}));
|
|
|
|
try
|
|
{
|
|
validated = ValidateToolDraft(
|
|
request,
|
|
contexts,
|
|
currentItems,
|
|
currentSpentTotal,
|
|
call.Arguments,
|
|
out toolSummary);
|
|
return Task.FromResult(JsonSerializer.Serialize(new
|
|
{
|
|
ok = true,
|
|
draft = new
|
|
{
|
|
suggestedTotal = validated.SuggestedTotal,
|
|
items = validated.Items.Select(item => new
|
|
{
|
|
categoryId = item.CategoryId,
|
|
amount = item.SuggestedAmount,
|
|
}),
|
|
},
|
|
warnings = validated.Warnings,
|
|
}));
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is JsonException or ArgumentException)
|
|
{
|
|
return Task.FromResult(JsonSerializer.Serialize(new
|
|
{
|
|
ok = false,
|
|
error = exception.Message,
|
|
}));
|
|
}
|
|
},
|
|
ct: ct);
|
|
|
|
if (validated is null)
|
|
{
|
|
logger.LogWarning(
|
|
"Budget refinement completed without tool call. user={UserId} ledger={LedgerId}",
|
|
userId,
|
|
ledgerId);
|
|
throw new InvalidOperationException("AI 没有生成可用的预算调整草稿");
|
|
}
|
|
|
|
var summary = string.IsNullOrWhiteSpace(response.Text)
|
|
? toolSummary ?? "已根据你的要求调整预算草稿"
|
|
: response.Text.Trim();
|
|
return validated with { AdjustmentSummary = summary };
|
|
}
|
|
|
|
private async Task<Dictionary<long, CategoryContext>> LoadCategoryContextsAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
int year,
|
|
int month,
|
|
CancellationToken ct)
|
|
{
|
|
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
|
|
var historyStart = currentStart.AddMonths(-3);
|
|
var categories = await db.Categories
|
|
.Where(category =>
|
|
!category.IsDeleted &&
|
|
category.Type == TransactionType.Expense &&
|
|
(category.UserId == null || category.UserId == userId))
|
|
.Select(category => new
|
|
{
|
|
category.Id,
|
|
category.Name,
|
|
category.IconKey,
|
|
category.ColorKey,
|
|
})
|
|
.ToListAsync(ct);
|
|
var transactions = await db.Transactions
|
|
.Where(transaction =>
|
|
transaction.UserId == userId &&
|
|
transaction.LedgerId == ledgerId &&
|
|
transaction.Type == TransactionType.Expense &&
|
|
transaction.OccurredAt >= historyStart &&
|
|
transaction.OccurredAt < currentEnd)
|
|
.Select(transaction => new
|
|
{
|
|
transaction.CategoryId,
|
|
transaction.Amount,
|
|
transaction.OccurredAt,
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
return categories.ToDictionary(category => category.Id, category =>
|
|
{
|
|
var currentSpent = transactions
|
|
.Where(transaction =>
|
|
transaction.CategoryId == category.Id &&
|
|
transaction.OccurredAt >= currentStart)
|
|
.Sum(transaction => transaction.Amount);
|
|
var history = Enumerable.Range(1, 3)
|
|
.Select(offset =>
|
|
{
|
|
var start = currentStart.AddMonths(-offset);
|
|
var end = start.AddMonths(1);
|
|
return transactions
|
|
.Where(transaction =>
|
|
transaction.CategoryId == category.Id &&
|
|
transaction.OccurredAt >= start &&
|
|
transaction.OccurredAt < end)
|
|
.Sum(transaction => transaction.Amount);
|
|
})
|
|
.Where(amount => amount > 0)
|
|
.ToList();
|
|
return new CategoryContext(
|
|
category.Id,
|
|
category.Name,
|
|
category.IconKey,
|
|
category.ColorKey,
|
|
currentSpent,
|
|
history.Count == 0 ? 0 : history.Average(),
|
|
history.Count >= 3 ? "high" : history.Count > 0 ? "medium" : "low");
|
|
});
|
|
}
|
|
|
|
private static string BuildSystemPrompt(
|
|
RefineBudgetRecommendationRequest request,
|
|
IReadOnlyDictionary<long, CategoryContext> contexts,
|
|
IReadOnlyDictionary<long, decimal> currentItems,
|
|
decimal currentSpentTotal)
|
|
{
|
|
var history = (request.History ?? [])
|
|
.Where(turn => turn.Role is "user" or "assistant")
|
|
.TakeLast(12)
|
|
.Select(turn => new
|
|
{
|
|
role = turn.Role,
|
|
content = turn.Content.Length <= 500
|
|
? turn.Content
|
|
: turn.Content[..500],
|
|
});
|
|
var payload = new
|
|
{
|
|
year = request.Year,
|
|
month = request.Month,
|
|
currentDraft = new
|
|
{
|
|
suggestedTotal = request.CurrentDraft.SuggestedTotal,
|
|
items = currentItems.Select(item => new
|
|
{
|
|
categoryId = item.Key,
|
|
amount = item.Value,
|
|
}),
|
|
},
|
|
allowedCategories = contexts.Values.Select(context => new
|
|
{
|
|
categoryId = context.Id,
|
|
name = context.Name,
|
|
currentSpent = context.CurrentSpent,
|
|
historicalAverage = context.HistoricalAverage,
|
|
}),
|
|
currentSpentTotal,
|
|
recentConversation = history,
|
|
};
|
|
return """
|
|
你是预算草稿调整 Agent。根据用户的最新指令和给定真实数据,必须调用 adjust_budget_draft。
|
|
工具参数必须返回完整草稿;没有要求改变的分类保持不变。只能使用 allowedCategories 中的 categoryId。
|
|
分类金额不得低于 currentSpent,总预算不得低于所有分类预算之和,也不得低于本月总支出。
|
|
如果用户要求违反约束,给出最接近的有效草稿,并在 summary 中说明。
|
|
工具成功后只回复一句简短、自然的调整说明,不要声称预算已经保存。
|
|
|
|
财务上下文:
|
|
""" + JsonSerializer.Serialize(payload);
|
|
}
|
|
|
|
internal static BudgetRecommendationResponse ValidateToolDraft(
|
|
RefineBudgetRecommendationRequest request,
|
|
IReadOnlyDictionary<long, CategoryContext> contexts,
|
|
IReadOnlyDictionary<long, decimal> currentItems,
|
|
decimal currentSpentTotal,
|
|
string arguments,
|
|
out string summary)
|
|
{
|
|
using var document = JsonDocument.Parse(arguments);
|
|
var root = document.RootElement;
|
|
if (!root.TryGetProperty("items", out var itemsNode) ||
|
|
itemsNode.ValueKind != JsonValueKind.Array)
|
|
throw new ArgumentException("AI 返回的预算分类无效");
|
|
|
|
summary = root.TryGetProperty("summary", out var summaryNode)
|
|
? summaryNode.GetString()?.Trim() ?? ""
|
|
: "";
|
|
var warnings = new List<string>();
|
|
var proposed = new Dictionary<long, decimal>();
|
|
foreach (var item in itemsNode.EnumerateArray())
|
|
{
|
|
if (!item.TryGetProperty("categoryId", out var categoryNode) ||
|
|
!categoryNode.TryGetInt64(out var categoryId) ||
|
|
!contexts.TryGetValue(categoryId, out var context))
|
|
{
|
|
warnings.Add("已忽略不存在或不可用的分类");
|
|
continue;
|
|
}
|
|
if (!item.TryGetProperty("amount", out var amountNode) ||
|
|
!amountNode.TryGetDecimal(out var amount))
|
|
{
|
|
warnings.Add($"“{context.Name}”金额无效,已保留原值");
|
|
continue;
|
|
}
|
|
amount = Math.Max(0, decimal.Round(amount, 2));
|
|
if (amount < context.CurrentSpent)
|
|
{
|
|
amount = context.CurrentSpent;
|
|
warnings.Add($"“{context.Name}”不能低于已花 ¥{context.CurrentSpent:0.##}");
|
|
}
|
|
proposed[categoryId] = amount;
|
|
}
|
|
|
|
foreach (var current in currentItems)
|
|
{
|
|
if (!proposed.ContainsKey(current.Key))
|
|
{
|
|
proposed[current.Key] = current.Value;
|
|
warnings.Add($"“{contexts[current.Key].Name}”未明确调整,已保留原值");
|
|
}
|
|
}
|
|
if (proposed.Count == 0)
|
|
throw new ArgumentException("AI 没有返回可用的分类预算");
|
|
|
|
var requestedTotal =
|
|
root.TryGetProperty("suggestedTotal", out var totalNode) &&
|
|
totalNode.TryGetDecimal(out var parsedTotal)
|
|
? Math.Max(0, decimal.Round(parsedTotal, 2))
|
|
: Math.Max(0, request.CurrentDraft.SuggestedTotal);
|
|
var categoryTotal = proposed.Values.Sum();
|
|
var minimumTotal = Math.Max(categoryTotal, currentSpentTotal);
|
|
if (requestedTotal < minimumTotal)
|
|
{
|
|
requestedTotal = minimumTotal;
|
|
warnings.Add($"总预算不能低于分类合计或本月已花,已调整为 ¥{minimumTotal:0.##}");
|
|
}
|
|
|
|
var recommendationItems = proposed
|
|
.Where(item => item.Value > 0)
|
|
.Select(item =>
|
|
{
|
|
var context = contexts[item.Key];
|
|
return new BudgetRecommendationItemDto(
|
|
context.Id,
|
|
context.Name,
|
|
context.IconKey,
|
|
item.Value,
|
|
context.CurrentSpent,
|
|
context.HistoricalAverage,
|
|
context.Confidence,
|
|
context.ColorKey);
|
|
})
|
|
.OrderByDescending(item => item.SuggestedAmount)
|
|
.ToList();
|
|
return new BudgetRecommendationResponse(
|
|
request.Year,
|
|
request.Month,
|
|
requestedTotal,
|
|
"预算草稿已调整,确认应用前不会写入正式预算。",
|
|
recommendationItems,
|
|
summary,
|
|
warnings.Distinct().ToList());
|
|
}
|
|
|
|
internal sealed record CategoryContext(
|
|
long Id,
|
|
string Name,
|
|
string IconKey,
|
|
string ColorKey,
|
|
decimal CurrentSpent,
|
|
decimal HistoricalAverage,
|
|
string Confidence);
|
|
}
|