Files
jizhi/backend/MiaoJiZhang.Api/Services/AgentService.cs
T

802 lines
30 KiB
C#

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<Transaction> Transactions);
public class AgentService(
AppDbContext db,
ILlmClient llm,
BudgetPushService budgetPush,
ILogger<AgentService> 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<AgentToolDefinition> 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<AgentToolDefinition> DraftTools =
[
new(
"prepare_transactions",
"解析明确的自然语言交易草稿。金额或收支方向不明确时不要调用。",
TransactionItemsSchema),
];
public async Task<AgentTurnResult> RunAsync(
long userId,
long ledgerId,
bool allowWrites,
string userText,
ChatMessage sourceMessage,
string personaPrompt,
string conversationContext,
Func<string, CancellationToken, Task>? onToken = null,
Func<string, CancellationToken, Task>? onToolRunning = null,
CancellationToken ct = default)
{
if (!llm.IsEnabled)
throw new InvalidOperationException("AI 功能尚未配置");
if (sourceMessage.Id == 0)
await db.SaveChangesAsync(ct);
var created = new List<Transaction>();
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<IReadOnlyList<ParsedBill>> ParseDraftsAsync(
long userId,
string text,
CancellationToken ct = default)
{
if (!llm.IsEnabled)
throw new InvalidOperationException("AI 功能尚未配置");
var drafts = new List<ParsedBill>();
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<string> CreateTransactionsAsync(
long userId,
long ledgerId,
string sourceText,
ChatMessage sourceMessage,
string arguments,
List<Transaction> created,
Func<bool> 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<Transaction>();
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<List<ParsedBill>> 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<ParsedBill>();
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<string> 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<string> 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<string> 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<Category> 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<long> 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<JsonElement>(json);
private static string Serialize(object value) =>
JsonSerializer.Serialize(value, JsonOptions);
}