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 logger) { private static readonly AgentToolDefinition AdjustBudgetTool = new( "adjust_budget_draft", "根据用户追加指令调整当前预算草稿。必须返回完整预算草稿,不能直接写入数据库。", JsonSerializer.Deserialize( """ { "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 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.Type == TransactionType.Transfer && transaction.TransferDirection == TransferDirection.Out) && 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> 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.Type == TransactionType.Transfer && transaction.TransferDirection == TransferDirection.Out) && 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 contexts, IReadOnlyDictionary 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 contexts, IReadOnlyDictionary 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(); var proposed = new Dictionary(); 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); }