Initial project import
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user