171 lines
7.6 KiB
C#
171 lines
7.6 KiB
C#
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);
|
||
|
||
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 == TransactionType.Income ? "收入" : "支出";
|
||
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 == TransactionType.Income
|
||
? $"收入到账!{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;
|
||
}
|
||
}
|
||
|
||
|