using System.Globalization; using System.Text.Json; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Services; public record AgentTurnResult( string Reply, IReadOnlyList Transactions); public class AgentService( AppDbContext db, ILlmClient llm, BudgetPushService budgetPush, ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly object TransactionItemsSchema = Schema( """ { "type": "object", "properties": { "items": { "type": "array", "minItems": 1, "maxItems": 10, "items": { "type": "object", "properties": { "type": { "type": "string", "enum": ["expense", "income", "transfer"], "description": "账单类型;明确转账时使用 transfer" }, "amount": { "type": "number", "exclusiveMinimum": 0 }, "categoryName": { "type": "string", "description": "支出分类:餐饮、饮品、购物、交通、住房、娱乐、医疗、学习、服饰、人情、旅行、其他;收入分类:工资、奖金、理财、兼职、红包、报销、其他" }, "note": { "type": "string", "description": "适合明细列表展示的简短备注,不要照抄用户整句话" }, "paymentMethod": { "type": "string", "description": "可选,例如微信支付、支付宝、现金" }, "transferDirection": { "type": "string", "enum": ["in", "out"], "description": "type=transfer 时必填,转入为 in,转出为 out" }, "counterparty": { "type": "string", "description": "转账对方,可选" }, "occurredAt": { "type": "string", "description": "可选,ISO 8601 时间;未提时间不要填写" } }, "required": ["type", "amount", "categoryName", "note"], "additionalProperties": false } } }, "required": ["items"], "additionalProperties": false } """); private static readonly IReadOnlyList ChatTools = [ new( "create_transactions", "仅当每笔交易的金额和收入/支出方向都明确时,创建一笔或多笔账单。裸金额、方向冲突或不确定时不得调用,应先询问用户。", TransactionItemsSchema), new( "get_financial_summary", "读取用户默认账本在指定周期内的真实收入、支出、结余和分类汇总。", Schema( """ { "type": "object", "properties": { "period": { "type": "string", "enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"] } }, "required": ["period"], "additionalProperties": false } """)), new( "list_transactions", "读取用户默认账本的近期真实账单,可按周期、收支方向和分类筛选。", Schema( """ { "type": "object", "properties": { "period": { "type": "string", "enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"] }, "type": { "type": "string", "enum": ["expense", "income", "transfer"] }, "categoryName": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 10 } }, "required": ["period"], "additionalProperties": false } """)), new( "get_budget_status", "读取用户默认账本某月的总预算、分类预算、已花金额和周期预算状态。", Schema( """ { "type": "object", "properties": { "year": { "type": "integer", "minimum": 2000, "maximum": 2200 }, "month": { "type": "integer", "minimum": 1, "maximum": 12 } }, "required": ["year", "month"], "additionalProperties": false } """)), ]; private static readonly IReadOnlyList DraftTools = [ new( "prepare_transactions", "解析明确的自然语言交易草稿。金额或收支方向不明确时不要调用。", TransactionItemsSchema), ]; public async Task RunAsync( long userId, long ledgerId, bool allowWrites, string userText, ChatMessage sourceMessage, string personaPrompt, string conversationContext, Func? onToken = null, Func? onToolRunning = null, CancellationToken ct = default) { if (!llm.IsEnabled) throw new InvalidOperationException("AI 功能尚未配置"); if (sourceMessage.Id == 0) await db.SaveChangesAsync(ct); var created = new List(); var writeExecuted = false; var response = await llm.RunAgentAsync( BuildSystemPrompt(personaPrompt, conversationContext), userText, ChatTools, async (call, toolCt) => { logger.LogInformation( "Executing AI agent tool. user={UserId} tool={Tool} callId={CallId}", userId, call.Name, call.CallId); return call.Name switch { "create_transactions" when allowWrites => await CreateTransactionsAsync( userId, ledgerId, userText, sourceMessage, call.Arguments, created, () => writeExecuted, () => writeExecuted = true, toolCt), "create_transactions" => Serialize(new { ok = false, error = "AI 自动入账已关闭,请在记一笔页面手动确认", }), "get_financial_summary" => await FinancialSummaryAsync( userId, ledgerId, call.Arguments, toolCt), "list_transactions" => await ListTransactionsAsync( userId, ledgerId, call.Arguments, toolCt), "get_budget_status" => await BudgetStatusAsync( userId, ledgerId, call.Arguments, toolCt), _ => Serialize(new { ok = false, error = "未知工具" }), }; }, onToken, onToolRunning, ct); return new AgentTurnResult(response.Text, created); } public async Task> ParseDraftsAsync( long userId, string text, CancellationToken ct = default) { if (!llm.IsEnabled) throw new InvalidOperationException("AI 功能尚未配置"); var drafts = new List(); await llm.RunAgentAsync( """ 你是记账草稿解析器。只有金额和收入/支出方向都明确时才调用 prepare_transactions。 收入包括赚到、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款等。 “100”这类裸金额、查询问题和普通聊天都不要调用工具。 可以一次提取多笔交易。不得把收入写成支出,也不得虚构金额。 """, text, DraftTools, async (call, toolCt) => { if (call.Name != "prepare_transactions") return Serialize(new { ok = false, error = "未知工具" }); var parsed = await ParseTransactionItemsAsync( userId, call.Arguments, toolCt); drafts.AddRange(parsed); return Serialize(new { ok = true, count = parsed.Count, items = parsed.Select(ToToolItem), }); }, ct: ct); return drafts; } private string BuildSystemPrompt(string personaPrompt, string conversationContext) { var now = ChinaClock.Now; return $""" {personaPrompt} 你同时是记之的财务 Agent。当前北京时间:{now:yyyy-MM-dd HH:mm:ss}。 记账必须通过 create_transactions;查询账本必须调用对应只读工具,不能凭空回答金额。 只有每笔金额和收入/支出方向都明确时才允许自动入账。 “我赚了100”“工资到账5000”“收到退款20”是收入;“午饭30”“买衣服200”是支出。 “100”这类裸金额、方向冲突或不确定表达必须先追问,不得默认支出。 同一句有多笔交易时一次传入全部 items,可以同时包含收入和支出。 工具参数 type 是必填项,绝不能省略;工具报错时向用户说明,不得假装已入账。 工具返回的数据是唯一事实来源。回复简短自然,已入账时明确说收入或支出。 不得删除账单、修改历史账单或修改预算。 {conversationContext} """; } private async Task CreateTransactionsAsync( long userId, long ledgerId, string sourceText, ChatMessage sourceMessage, string arguments, List created, Func wasExecuted, Action markExecuted, CancellationToken ct) { if (wasExecuted()) return Serialize(new { ok = false, error = "本轮已经执行过入账,不能重复写入" }); var bills = await ParseTransactionItemsAsync(userId, arguments, ct); if (bills.Count == 0) return Serialize(new { ok = false, error = "没有有效交易,未写入" }); markExecuted(); var now = DateTime.UtcNow; var pending = new List(); foreach (var bill in bills) { var category = await db.Categories.FirstAsync( c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection), ct); if (category.Type != bill.Type.CategoryType(bill.TransferDirection)) throw new InvalidOperationException("交易类型与分类类型不一致"); pending.Add(new Transaction { LedgerId = ledgerId, UserId = userId, CategoryId = category.Id, Category = category, Type = bill.Type, TransferDirection = bill.TransferDirection, Counterparty = bill.Counterparty, Amount = bill.Amount, Note = NormalizeNote(bill.Note, sourceText, category.Name), PaymentMethod = bill.PaymentMethod, OccurredAt = bill.OccurredAt ?? now, Source = TransactionSource.AiChat, SourceText = sourceText, SourceChatMessageId = sourceMessage.Id, CreatedAt = now, UpdatedAt = now, }); } db.Transactions.AddRange(pending); await using var writeScope = await db.Database.BeginTransactionAsync(ct); try { await db.SaveChangesAsync(ct); await budgetPush.EvaluateAsync(userId, pending .Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection)) .Select(transaction => new BudgetExpenseChange( transaction.LedgerId, transaction.CategoryId, transaction.OccurredAt, transaction.Amount)), ct); await db.SaveChangesAsync(ct); await writeScope.CommitAsync(ct); } catch { await writeScope.RollbackAsync(ct); foreach (var transaction in pending) db.Entry(transaction).State = EntityState.Detached; throw; } created.AddRange(pending); return Serialize(new { ok = true, count = created.Count, items = created.Select(transaction => new { id = transaction.Id, type = transaction.Type.ToWire(), transferDirection = transaction.TransferDirection.ToWire(), transaction.Counterparty, amount = transaction.Amount, categoryName = transaction.Category.Name, note = transaction.Note, }), }); } private async Task> ParseTransactionItemsAsync( long userId, string arguments, CancellationToken ct) { using var document = JsonDocument.Parse(arguments); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) { throw new InvalidOperationException("工具参数缺少 items"); } if (items.GetArrayLength() is < 1 or > 10) throw new InvalidOperationException("每次只能处理 1 到 10 笔交易"); var bills = new List(); foreach (var item in items.EnumerateArray()) { var typeText = RequiredString(item, "type").ToLowerInvariant(); var type = typeText switch { "income" => TransactionType.Income, "expense" => TransactionType.Expense, "transfer" => TransactionType.Transfer, _ => throw new InvalidOperationException( "交易 type 必须是 income、expense 或 transfer"), }; TransferDirection? transferDirection = null; if (type == TransactionType.Transfer) { transferDirection = RequiredString(item, "transferDirection") switch { "in" => TransferDirection.In, "out" => TransferDirection.Out, _ => throw new InvalidOperationException("转账方向必须是 in 或 out"), }; } if (!item.TryGetProperty("amount", out var amountNode) || !amountNode.TryGetDecimal(out var amount) || amount <= 0) { throw new InvalidOperationException("交易金额必须大于 0"); } var categoryName = RequiredString(item, "categoryName").Trim(); var note = RequiredString(item, "note").Trim(); if (categoryName.Length > 20) categoryName = categoryName[..20]; if (note.Length > 30) note = note[..30]; var category = await ResolveCategoryAsync( userId, type.CategoryType(transferDirection), categoryName, ct); string? paymentMethod = null; if (item.TryGetProperty("paymentMethod", out var paymentNode) && paymentNode.ValueKind == JsonValueKind.String) { paymentMethod = paymentNode.GetString()?.Trim(); if (paymentMethod?.Length > 20) paymentMethod = paymentMethod[..20]; } DateTime? occurredAt = null; if (item.TryGetProperty("occurredAt", out var occurredNode) && occurredNode.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(occurredNode.GetString())) { if (!DateTimeOffset.TryParse( occurredNode.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsedTime)) { throw new InvalidOperationException("交易时间格式无效"); } occurredAt = parsedTime.UtcDateTime; } bills.Add(new ParsedBill( type, category.Id, category.Name, string.IsNullOrWhiteSpace(note) ? category.Name : note, amount, paymentMethod, occurredAt, transferDirection, OptionalString(item, "counterparty", 100))); } return bills; } private async Task FinancialSummaryAsync( long userId, long ledgerId, string arguments, CancellationToken ct) { using var document = JsonDocument.Parse(arguments); var period = RequiredString(document.RootElement, "period"); var (start, end, label) = ResolvePeriod(period); var transactions = await db.Transactions .Include(t => t.Category) .Where(t => t.UserId == userId && t.LedgerId == ledgerId && t.OccurredAt >= start && t.OccurredAt < end) .ToListAsync(ct); var income = transactions .Where(t => t.Type.IsIncome(t.TransferDirection)) .Sum(t => t.Amount); var expense = transactions .Where(t => t.Type.IsExpense(t.TransferDirection)) .Sum(t => t.Amount); var categories = transactions .GroupBy(t => new { EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense", t.Category.Name, }) .Select(group => new { type = group.Key.EffectiveType, categoryName = group.Key.Name, amount = group.Sum(t => t.Amount), }) .OrderByDescending(item => item.amount) .ToList(); return Serialize(new { ok = true, period = label, income, expense, balance = income - expense, count = transactions.Count, categories, }); } private async Task ListTransactionsAsync( long userId, long ledgerId, string arguments, CancellationToken ct) { using var document = JsonDocument.Parse(arguments); var root = document.RootElement; var period = RequiredString(root, "period"); var (start, end, label) = ResolvePeriod(period); var query = db.Transactions .Include(t => t.Category) .Where(t => t.UserId == userId && t.LedgerId == ledgerId && t.OccurredAt >= start && t.OccurredAt < end); if (root.TryGetProperty("type", out var typeNode) && typeNode.ValueKind == JsonValueKind.String) { var type = typeNode.GetString() switch { "income" => TransactionType.Income, "expense" => TransactionType.Expense, "transfer" => TransactionType.Transfer, _ => throw new InvalidOperationException("筛选类型无效"), }; query = query.Where(t => t.Type == type); } if (root.TryGetProperty("categoryName", out var categoryNode) && categoryNode.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(categoryNode.GetString())) { var categoryName = categoryNode.GetString()!.Trim(); query = query.Where(t => t.Category.Name == categoryName); } var limit = root.TryGetProperty("limit", out var limitNode) && limitNode.TryGetInt32(out var requestedLimit) ? Math.Clamp(requestedLimit, 1, 10) : 10; var transactions = await query .OrderByDescending(t => t.OccurredAt) .Take(limit) .ToListAsync(ct); return Serialize(new { ok = true, period = label, count = transactions.Count, items = transactions.Select(transaction => new { id = transaction.Id, type = transaction.Type.ToWire(), transferDirection = transaction.TransferDirection.ToWire(), transaction.Counterparty, transaction.Amount, categoryName = transaction.Category.Name, transaction.Note, occurredAt = transaction.OccurredAt, }), }); } private async Task BudgetStatusAsync( long userId, long ledgerId, string arguments, CancellationToken ct) { using var document = JsonDocument.Parse(arguments); var root = document.RootElement; if (!root.TryGetProperty("year", out var yearNode) || !yearNode.TryGetInt32(out var year) || !root.TryGetProperty("month", out var monthNode) || !monthNode.TryGetInt32(out var month) || year is < 2000 or > 2200 || month is < 1 or > 12) { throw new InvalidOperationException("预算年月无效"); } var period = year * 100 + month; var (start, end) = ChinaClock.MonthRangeUtc(year, month); var budgetRows = await db.Budgets .Where(b => b.UserId == userId && b.LedgerId == ledgerId && (b.Period == period || b.Period == 0)) .ToListAsync(ct); var effective = budgetRows .GroupBy(b => b.CategoryId) .Select(group => group .OrderByDescending(b => b.Period == period) .First()) .ToList(); var categoryIds = effective .Where(budget => budget.CategoryId.HasValue) .Select(budget => budget.CategoryId!.Value) .Distinct() .ToList(); var categoryNames = await db.Categories .Where(category => categoryIds.Contains(category.Id)) .ToDictionaryAsync(category => category.Id, category => category.Name, ct); var expenses = await db.Transactions .Where(t => t.UserId == userId && t.LedgerId == ledgerId && (t.Type == TransactionType.Expense || t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) && t.OccurredAt >= start && t.OccurredAt < end) .Select(t => new { t.CategoryId, t.Amount }) .ToListAsync(ct); var spentByCategory = expenses .GroupBy(t => t.CategoryId) .ToDictionary(group => group.Key, group => group.Sum(t => t.Amount)); var totalSpent = expenses.Sum(t => t.Amount); return Serialize(new { ok = true, year, month, totalSpent, budgets = effective.Select(budget => new { categoryName = budget.CategoryId.HasValue ? categoryNames.GetValueOrDefault(budget.CategoryId.Value, "其他") : "总预算", amount = budget.Amount, spent = budget.CategoryId.HasValue ? spentByCategory.GetValueOrDefault(budget.CategoryId.Value) : totalSpent, remaining = budget.Amount - ( budget.CategoryId.HasValue ? spentByCategory.GetValueOrDefault(budget.CategoryId.Value) : totalSpent), recurring = budget.Period == 0, }), }); } private static string NormalizeNote( string note, string sourceText, string categoryName) { var trimmed = note.Trim(); var comparableNote = string.Concat(trimmed.Where(char.IsLetterOrDigit)); var comparableSource = string.Concat( sourceText.Trim().Where(char.IsLetterOrDigit)); if (string.Equals( comparableNote, comparableSource, StringComparison.OrdinalIgnoreCase)) { return categoryName; } return trimmed.Length > 16 ? trimmed[..16] : trimmed; } private async Task ResolveCategoryAsync( long userId, TransactionType type, string requestedName, CancellationToken ct) { var category = await db.Categories .Where(c => !c.IsDeleted && c.Type == type && c.Name == requestedName && (c.UserId == null || c.UserId == userId)) .OrderByDescending(c => c.UserId == userId) .FirstOrDefaultAsync(ct); return category ?? await db.Categories.FirstAsync( c => !c.IsDeleted && c.UserId == null && c.Type == type && c.Name == "其他", ct); } private async Task DefaultLedgerIdAsync(long userId, CancellationToken ct) => await db.Ledgers .Where(l => l.OwnerId == userId && l.IsDefault) .Select(l => l.Id) .FirstAsync(ct); private static object ToToolItem(ParsedBill bill) => new { type = bill.Type.ToWire(), transferDirection = bill.TransferDirection.ToWire(), bill.Counterparty, bill.Amount, bill.CategoryName, bill.Note, bill.PaymentMethod, occurredAt = bill.OccurredAt, }; private static string RequiredString(JsonElement root, string name) { if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(node.GetString())) { throw new InvalidOperationException($"工具参数缺少 {name}"); } return node.GetString()!; } private static string? OptionalString(JsonElement root, string name, int maxLength) { if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String) return null; var value = node.GetString()?.Trim(); if (string.IsNullOrEmpty(value)) return null; return value.Length <= maxLength ? value : value[..maxLength]; } private static (DateTime Start, DateTime End, string Label) ResolvePeriod( string period) { var localNow = ChinaClock.Now; var today = localNow.Date; DateTime startLocal; DateTime endLocal; string label; switch (period) { case "today": startLocal = today; endLocal = today.AddDays(1); label = "今天"; break; case "yesterday": startLocal = today.AddDays(-1); endLocal = today; label = "昨天"; break; case "this_week": var offset = ((int)today.DayOfWeek + 6) % 7; startLocal = today.AddDays(-offset); endLocal = startLocal.AddDays(7); label = "本周"; break; case "this_month": startLocal = new DateTime(today.Year, today.Month, 1); endLocal = startLocal.AddMonths(1); label = "本月"; break; case "last_month": endLocal = new DateTime(today.Year, today.Month, 1); startLocal = endLocal.AddMonths(-1); label = "上月"; break; case "this_year": startLocal = new DateTime(today.Year, 1, 1); endLocal = startLocal.AddYears(1); label = "今年"; break; default: throw new InvalidOperationException("查询周期无效"); } return (ChinaClock.ToUtc(startLocal), ChinaClock.ToUtc(endLocal), label); } private static object Schema(string json) => JsonSerializer.Deserialize(json); private static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions); }