Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -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);
}