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

179 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.RegularExpressions;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public record ParsedBill(
TransactionType Type,
long CategoryId,
string CategoryName,
string Note,
decimal Amount,
string? PaymentMethod,
DateTime? OccurredAt = null,
TransferDirection? TransferDirection = null,
string? Counterparty = null);
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
/// <summary>
/// Cleans legacy AI notes for list display only. It never decides intent, type, or category.
/// </summary>
public static class TransactionNoteFormatter
{
private static readonly Regex AmountRegex = new(
@"(?<!\d)(\d+(?:\.\d{1,2})?)\s*(?:元|块钱|块|rmb|人民币)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string BuildNote(string sourceText, string fallback)
{
var note = AmountRegex.Replace(sourceText, " ");
note = Regex.Replace(
note,
@"(?:微信|支付宝|现金)(?:支付|付款|收款)?",
" ");
note = Regex.Replace(
note,
@"^\s*(?:(?:今天|昨天|刚才|刚刚|我|我们)\s*)+",
"");
note = Regex.Replace(
note,
@"^\s*(?:在)?(?:淘宝|京东|拼多多|美团|饿了么)\s*",
"");
note = Regex.Replace(
note,
@"^\s*(?:赚了?|赚到|挣了?|挣到|收到|获得|领到|收入|入账|进账|到账|发了?|发放|报销了?|退款了?|退回|返现)\s*",
"");
note = Regex.Replace(
note,
@"^\s*(?:买了|买|花了|花|付了|支付了|消费了|吃了|吃|喝了|喝)\s*",
"");
note = Regex.Replace(
note,
@"\s*(?:花了|花|用了|付了|支付了|消费了|赚了|挣了|到账|入账)\s*$",
"");
note = Regex.Replace(note, @"[,。!?,.!?::;\s]+", " ").Trim();
if (string.IsNullOrWhiteSpace(note)) note = fallback;
return note.Length > 20 ? note[..20] : note;
}
}
/// <summary>
/// AI 回复文案生成。当前为模板版(性格 × 口癖),后续接 LLM。
/// 性格模板与口癖均来自数据库(后台可配 —— 决策:Prompt 后台可调)。
/// </summary>
public class ReplyService(AppDbContext db)
{
public async Task<string> BillReplyAsync(long userId, ParsedBill bill)
{
var (persona, tic) = await GetPersonaAsync(userId);
var action = bill.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when bill.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
var body = persona switch
{
"gentle" => $"{action}记好啦~{bill.CategoryName} ¥{bill.Amount:F2}",
"strict" => $"已记录{action}{bill.CategoryName} ¥{bill.Amount:F2}。",
"meme" => $"{action}记上了!{bill.CategoryName} ¥{bill.Amount:F2},家人们谁懂啊",
_ => bill.Type.IsIncome(bill.TransferDirection)
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
};
return ApplyTic(body, persona, tic);
}
public async Task<string> QueryReplyAsync(long userId, decimal topAmount, string topCategory, double percent)
{
var (persona, tic) = await GetPersonaAsync(userId);
var body = persona switch
{
"gentle" => $"这个月「{topCategory}」花得最多,¥{topAmount:F0}(占 {percent:F0}%)~ 要不要我帮你定个小目标呀?",
"strict" => $"本月最大支出分类:{topCategory} ¥{topAmount:F2},占总支出 {percent:F0}%。建议设置分类预算。",
"meme" => $"必须是「{topCategory}」!¥{topAmount:F0},占了 {percent:F0}%,你这钱包是碎钞机吧 2333",
_ => $"是「{topCategory}」¥{topAmount:F0}(占 {percent:F0}%)!继续这么花,月底吃土",
};
return ApplyTic(body, persona, tic);
}
public async Task<string> ChatReplyAsync(long userId)
{
var (persona, tic) = await GetPersonaAsync(userId);
var body = persona == "gentle"
? "嗯嗯我在听~要记账直接说金额就行哦"
: "在听。想记账就直接说,比如「午饭 26」";
return ApplyTic(body, persona, tic);
}
public Task<string> MonthlyRoastAsync(
long userId,
decimal income,
decimal expense,
string topCategory,
decimal topAmount) =>
PeriodRoastAsync(userId, "这个月", income, expense, topCategory, topAmount);
public async Task<string> PeriodRoastAsync(
long userId,
string periodLabel,
decimal income,
decimal expense,
string topCategory,
decimal topAmount)
{
var (persona, tic) = await GetPersonaAsync(userId);
var ratio = income == 0 ? "N/A" : $"{expense / income * 100:F0}%";
var body = persona switch
{
"gentle" => $"{periodLabel}收入 ¥{income:F0}、支出 ¥{expense:F0},「{topCategory}」花了 ¥{topAmount:F0}。我们一起把结余慢慢提高一点吧。",
"strict" => $"{periodLabel}收支比 {ratio}。最大支出分类「{topCategory}」¥{topAmount:F2},建议继续按预算复盘。",
"meme" => $"{periodLabel}收入 ¥{income:F0},支出 ¥{expense:F0},「{topCategory}」就干掉 ¥{topAmount:F0},钱包这波压力不小。",
_ => $"{periodLabel}收入 ¥{income:F0},「{topCategory}」花了 ¥{topAmount:F0}。下个周期这个分类我会继续帮你盯着。",
};
return ApplyTic(body, persona, tic);
}
/// <summary>表情包回复(按性格;用户发表情包时调用)</summary>
public async Task<(string Text, string? StickerKey)> StickerReplyAsync(long userId, string stickerKey)
{
var (persona, tic) = await GetPersonaAsync(userId);
if (stickerKey == "salary")
{
var t = persona == "strict" ? "检测到收入信号。请报具体金额,我来记录。" : ApplyTic("哦?发工资了?多少多少,快报数,我帮你记收入!", persona, tic);
return (t, null);
}
var text = persona switch
{
"gentle" => "哈哈收到~心情好最重要啦",
"strict" => "已收到。请继续保持记账习惯。",
"meme" => "哈哈哈哈这表情包我先存了",
_ => ApplyTic("卖萌也没用!该省还得省", persona, tic),
};
// 回一个表情包(毒舌回生气,温柔回开心)
var replySticker = persona == "gentle" ? "happy" : "angry";
return (text, replySticker);
}
private async Task<(string Persona, string Tic)> GetPersonaAsync(long userId)
{
var setting = await db.AiCompanionSettings.FirstOrDefaultAsync(s => s.UserId == userId);
var personaKey = setting?.PersonaKey ?? "sassy_cat";
var avatarKey = setting?.AvatarKey ?? "cat";
var tic = await db.AiAvatars.Where(a => a.Key == avatarKey)
.Select(a => a.SpeechTic).FirstOrDefaultAsync() ?? "";
return (personaKey, tic);
}
/// <summary>口癖跟随形象(决策 20);严格管家不用口癖</summary>
private static string ApplyTic(string text, string persona, string tic)
{
if (string.IsNullOrEmpty(tic) || persona == "strict") return text;
return text + tic;
}
}