From 6ca962a187fa581f5f862e9a2b042af3dd0b1d01 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Thu, 12 Mar 2026 14:03:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9Eai=E5=88=86=E6=9E=90?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=82=A8=E5=80=BC=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E9=80=9A=E7=9F=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../echo/app/notification/NotificationDb.kt | 176 +++++++------- .../app/notification/NotificationRuleData.kt | 105 +++++++++ .../notification/NotificationRuleEngine.kt | 41 +++- scripts/rule-smoke.mjs | 50 ++++ scripts/sync-notification-rules.mjs | 17 +- src/config/aiStatus.js | 28 +++ src/config/ledger.js | 76 ++++++ src/config/notificationRules.js | 128 ++++++++-- src/config/transactionCategories.js | 11 +- src/lib/aiPrompt.js | 120 +++++++++- src/lib/sqlite.js | 14 ++ src/services/ai/aiService.js | 39 +++- src/services/ai/deepseekProvider.js | 48 +++- src/services/notificationRuleService.js | 44 +++- src/services/transactionService.js | 161 +++++++++++-- src/stores/transactions.js | 30 ++- src/stores/ui.js | 3 +- src/types/transaction.d.ts | 11 +- src/views/AddEntryView.vue | 2 +- src/views/AnalysisView.vue | 219 +++++++++++++----- src/views/HomeView.vue | 106 ++++++++- src/views/ListView.vue | 102 +++++++- 22 files changed, 1294 insertions(+), 237 deletions(-) create mode 100644 src/config/aiStatus.js create mode 100644 src/config/ledger.js diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt index 96ae052..4d11a2a 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt @@ -1,24 +1,22 @@ -package com.echo.app.notification +package com.echo.app.notification import android.content.Context +import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.util.Log import java.util.UUID import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +import kotlin.math.abs -// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库 +// Native SQLite access shared with @capacitor-community/sqlite. object NotificationDb { private const val TAG = "NotificationDb" - - // 注意:前端通过 @capacitor-community/sqlite 使用的 DB 名为 "echo_local" - // 插件在 Android 上实际创建的文件名为 "SQLite.db" private const val DB_FILE_NAME = "echo_localSQLite.db" private const val DB_VERSION = 1 - // transactions 表结构需与 src/lib/sqlite.js 保持完全一致 private const val SQL_CREATE_TRANSACTIONS = """ CREATE TABLE IF NOT EXISTS transactions ( id TEXT PRIMARY KEY NOT NULL, @@ -27,11 +25,24 @@ object NotificationDb { category TEXT DEFAULT 'Uncategorized', date TEXT NOT NULL, note TEXT, - sync_status INTEGER DEFAULT 0 + sync_status INTEGER DEFAULT 0, + entry_type TEXT DEFAULT 'expense', + fund_source_type TEXT DEFAULT 'cash', + fund_source_name TEXT, + fund_target_type TEXT DEFAULT 'merchant', + fund_target_name TEXT, + impact_expense INTEGER DEFAULT 1, + impact_income INTEGER DEFAULT 0, + ai_category TEXT, + ai_tags TEXT, + ai_confidence REAL, + ai_reason TEXT, + ai_status TEXT DEFAULT 'idle', + ai_model TEXT, + ai_normalized_merchant TEXT ); """ - // notifications_raw 表:用于调试与规则命中记录 private const val SQL_CREATE_NOTIFICATIONS_RAW = """ CREATE TABLE IF NOT EXISTS notifications_raw ( id TEXT PRIMARY KEY NOT NULL, @@ -47,18 +58,33 @@ object NotificationDb { ); """ - // 简单 SQLiteOpenHelper:主要用于定位与创建数据库文件 + private val TRANSACTION_REQUIRED_COLUMNS = linkedMapOf( + "entry_type" to "TEXT DEFAULT 'expense'", + "fund_source_type" to "TEXT DEFAULT 'cash'", + "fund_source_name" to "TEXT", + "fund_target_type" to "TEXT DEFAULT 'merchant'", + "fund_target_name" to "TEXT", + "impact_expense" to "INTEGER DEFAULT 1", + "impact_income" to "INTEGER DEFAULT 0", + "ai_category" to "TEXT", + "ai_tags" to "TEXT", + "ai_confidence" to "REAL", + "ai_reason" to "TEXT", + "ai_status" to "TEXT DEFAULT 'idle'", + "ai_model" to "TEXT", + "ai_normalized_merchant" to "TEXT", + ) + private class EchoDbHelper(context: Context) : SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) { override fun onCreate(db: SQLiteDatabase) { - // 正常情况下,数据表由前端首次启动时创建;此处作为兜底 db.execSQL(SQL_CREATE_TRANSACTIONS) db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - // 当前仅有 v1,暂不实现升级逻辑;未来如有版本变更再补充 + // No-op for now. } } @@ -69,22 +95,37 @@ object NotificationDb { private var schemaInitialized = false private fun getHelper(context: Context): EchoDbHelper { - // 使用 applicationContext 避免泄漏 Activity val appContext = context.applicationContext return helper ?: synchronized(this) { helper ?: EchoDbHelper(appContext).also { helper = it } } } + private fun ensureTableColumns(db: SQLiteDatabase, tableName: String, requiredColumns: Map) { + val existingColumns = mutableSetOf() + val cursor: Cursor = db.rawQuery("PRAGMA table_info($tableName)", null) + cursor.use { + val nameIndex = it.getColumnIndex("name") + while (it.moveToNext()) { + existingColumns += it.getString(nameIndex) + } + } + + requiredColumns.forEach { (columnName, definition) -> + if (!existingColumns.contains(columnName)) { + db.execSQL("ALTER TABLE $tableName ADD COLUMN $columnName $definition") + } + } + } + private fun ensureSchema(db: SQLiteDatabase) { if (schemaInitialized) return - // 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS,性能成本极低 db.execSQL(SQL_CREATE_TRANSACTIONS) db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) + ensureTableColumns(db, "transactions", TRANSACTION_REQUIRED_COLUMNS) schemaInitialized = true } - // 用于规则引擎输入的原始通知模型 data class RawNotification( val sourceId: String, val packageName: String, @@ -94,30 +135,19 @@ object NotificationDb { val postedAt: String ) - /** - * 处理单条通知: - * 1. 写入 notifications_raw,初始 status = 'unmatched' - * 2. 调用 NotificationRuleEngine 做规则解析 - * 3. 命中规则时: - * - 写入一条 transactions 记录 - * - 更新 notifications_raw.status = 'matched',补充 rule_id、transaction_id - * 4. 解析异常时: - * - 更新 notifications_raw.status = 'error',写入 error_message - */ fun processAndStoreNotification(context: Context, raw: RawNotification) { lock.withLock { val db = getHelper(context).writableDatabase ensureSchema(db) - val rawId = UUID.randomUUID().toString() - var status = "unmatched" - var ruleId: String? = null - var transactionId: String? = null - var errorMessage: String? = null + val rawId = UUID.randomUUID().toString() + var status = "unmatched" + var ruleId: String? = null + var transactionId: String? = null + var errorMessage: String? = null db.beginTransaction() try { - // 写入 notifications_raw(初始 unmatched) db.compileStatement( """ INSERT INTO notifications_raw ( @@ -127,16 +157,8 @@ object NotificationDb { ).apply { bindString(1, rawId) bindString(2, raw.sourceId) - if (raw.channel != null) { - bindString(3, raw.channel) - } else { - bindNull(3) - } - if (raw.title != null) { - bindString(4, raw.title) - } else { - bindNull(4) - } + if (raw.channel != null) bindString(3, raw.channel) else bindNull(3) + if (raw.title != null) bindString(4, raw.title) else bindNull(4) bindString(5, raw.text) bindString(6, raw.postedAt) bindString(7, status) @@ -144,34 +166,37 @@ object NotificationDb { close() } - // 调用规则引擎 val parsed = NotificationRuleEngine.parse(raw) if (parsed != null) { ruleId = parsed.ruleId - - // 与 JS 侧逻辑保持一致: - // requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown') val hasAmount = parsed.amount != 0.0 - val merchantKnown = - parsed.merchant.isNotBlank() && parsed.merchant != "Unknown" + val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown" val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown) if (!requiresConfirmation) { - // 统一按照设计文档:支出为负数,收入为正数 - val signedAmount = if (parsed.isExpense) { - -kotlin.math.abs(parsed.amount) - } else { - kotlin.math.abs(parsed.amount) - } + val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount) transactionId = UUID.randomUUID().toString() status = "matched" db.compileStatement( """ INSERT INTO transactions ( - id, amount, merchant, category, date, note, sync_status - ) VALUES (?, ?, ?, ?, ?, ?, ?) + id, + amount, + merchant, + category, + date, + note, + sync_status, + entry_type, + fund_source_type, + fund_source_name, + fund_target_type, + fund_target_name, + impact_expense, + impact_income + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent() ).apply { bindString(1, transactionId) @@ -179,23 +204,21 @@ object NotificationDb { bindString(3, parsed.merchant) bindString(4, parsed.category) bindString(5, raw.postedAt) - if (parsed.note != null) { - bindString(6, parsed.note) - } else { - bindNull(6) - } - // sync_status: 0 = pending(本地生成,尚未同步) + if (parsed.note != null) bindString(6, parsed.note) else bindNull(6) bindLong(7, 0L) + bindString(8, parsed.entryType) + bindString(9, parsed.fundSourceType) + bindString(10, parsed.fundSourceName) + bindString(11, parsed.fundTargetType) + bindString(12, parsed.fundTargetName) + bindLong(13, if (parsed.impactExpense) 1L else 0L) + bindLong(14, if (parsed.impactIncome) 1L else 0L) executeInsert() close() } - } else { - // 保持 status = 'unmatched',但记录命中的 rule_id,方便前端调试视图展示“有推荐规则” - status = "unmatched" } } - // 根据结果更新 notifications_raw 状态 db.compileStatement( """ UPDATE notifications_raw @@ -204,21 +227,9 @@ object NotificationDb { """.trimIndent() ).apply { bindString(1, status) - if (ruleId != null) { - bindString(2, ruleId) - } else { - bindNull(2) - } - if (transactionId != null) { - bindString(3, transactionId) - } else { - bindNull(3) - } - if (errorMessage != null) { - bindString(4, errorMessage) - } else { - bindNull(4) - } + if (ruleId != null) bindString(2, ruleId) else bindNull(2) + if (transactionId != null) bindString(3, transactionId) else bindNull(3) + if (errorMessage != null) bindString(4, errorMessage) else bindNull(4) bindString(5, rawId) executeUpdateDelete() close() @@ -226,8 +237,7 @@ object NotificationDb { db.setTransactionSuccessful() } catch (e: Exception) { - // 若解析阶段抛异常,尽量在同一事务中回写 error 状态 - Log.e(TAG, "处理通知并写入 SQLite 失败", e) + Log.e(TAG, "processAndStoreNotification failed", e) status = "error" errorMessage = e.message ?: e.toString() try { @@ -239,13 +249,13 @@ object NotificationDb { """.trimIndent() ).apply { bindString(1, status) - bindString(2, errorMessage!!) + bindString(2, errorMessage) bindString(3, rawId) executeUpdateDelete() close() } } catch (inner: Exception) { - Log.e(TAG, "更新 notifications_raw 错误状态失败", inner) + Log.e(TAG, "failed to update notifications_raw error state", inner) } } finally { db.endTransaction() diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt b/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt index 473680b..e1bead8 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt @@ -10,6 +10,15 @@ internal object NotificationRuleData { keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.EXPENSE, + entryType = "expense", + fundSourceType = "cash", + fundSourceName = "支付宝余额", + fundSourceNameFromChannel = false, + fundTargetType = "merchant", + fundTargetName = null, + fundTargetNameFromChannel = false, + impactExpense = true, + impactIncome = false, defaultCategory = "Food", autoCapture = false, merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)), @@ -22,6 +31,15 @@ internal object NotificationRuleData { keywords = listOf("到账", "收入", "收款", "入账"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.INCOME, + entryType = "income", + fundSourceType = "external", + fundSourceName = "外部转入", + fundSourceNameFromChannel = false, + fundTargetType = "cash", + fundTargetName = "支付宝余额", + fundTargetNameFromChannel = false, + impactExpense = false, + impactIncome = true, defaultCategory = "Income", autoCapture = true, merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)), @@ -34,6 +52,15 @@ internal object NotificationRuleData { keywords = listOf("支付", "支出", "扣款", "消费成功"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.EXPENSE, + entryType = "expense", + fundSourceType = "cash", + fundSourceName = "微信余额", + fundSourceNameFromChannel = false, + fundTargetType = "merchant", + fundTargetName = null, + fundTargetNameFromChannel = false, + impactExpense = true, + impactIncome = false, defaultCategory = "Groceries", autoCapture = false, merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), @@ -46,6 +73,15 @@ internal object NotificationRuleData { keywords = listOf("收款", "到账", "入账", "转入"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.INCOME, + entryType = "income", + fundSourceType = "external", + fundSourceName = "外部转入", + fundSourceNameFromChannel = false, + fundTargetType = "cash", + fundTargetName = "微信余额", + fundTargetNameFromChannel = false, + impactExpense = false, + impactIncome = true, defaultCategory = "Income", autoCapture = true, merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), @@ -58,6 +94,15 @@ internal object NotificationRuleData { keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.INCOME, + entryType = "income", + fundSourceType = "external", + fundSourceName = "外部转入", + fundSourceNameFromChannel = false, + fundTargetType = "bank", + fundTargetName = "银行卡", + fundTargetNameFromChannel = false, + impactExpense = false, + impactIncome = true, defaultCategory = "Income", autoCapture = true, merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), @@ -70,11 +115,62 @@ internal object NotificationRuleData { keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"), requiredTextPatterns = emptyList(), direction = NotificationRuleDirection.EXPENSE, + entryType = "expense", + fundSourceType = "bank", + fundSourceName = "银行卡", + fundSourceNameFromChannel = false, + fundTargetType = "merchant", + fundTargetName = null, + fundTargetNameFromChannel = false, + impactExpense = true, + impactIncome = false, defaultCategory = "Expense", autoCapture = false, merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), defaultMerchant = null, ), + NotificationRuleDefinition( + id = "stored-value-topup", + label = "储值账户充值", + channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"), + keywords = listOf("充值", "充值成功", "充值到账", "钱包充值", "卡内充值"), + requiredTextPatterns = emptyList(), + direction = NotificationRuleDirection.EXPENSE, + entryType = "transfer", + fundSourceType = "cash", + fundSourceName = "现金账户", + fundSourceNameFromChannel = false, + fundTargetType = "stored_value", + fundTargetName = null, + fundTargetNameFromChannel = true, + impactExpense = false, + impactIncome = false, + defaultCategory = "Transfer", + autoCapture = true, + merchantPattern = null, + defaultMerchant = "储值账户充值", + ), + NotificationRuleDefinition( + id = "stored-value-refund", + label = "储值账户退款", + channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"), + keywords = listOf("退款", "退资", "退卡", "余额退回", "退款成功"), + requiredTextPatterns = emptyList(), + direction = NotificationRuleDirection.INCOME, + entryType = "transfer", + fundSourceType = "stored_value", + fundSourceName = null, + fundSourceNameFromChannel = true, + fundTargetType = "cash", + fundTargetName = "现金账户", + fundTargetNameFromChannel = false, + impactExpense = false, + impactIncome = false, + defaultCategory = "Transfer", + autoCapture = true, + merchantPattern = null, + defaultMerchant = "储值账户退款", + ), NotificationRuleDefinition( id = "transit-card-expense", label = "公交出行", @@ -82,6 +178,15 @@ internal object NotificationRuleData { keywords = emptyList(), requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"), direction = NotificationRuleDirection.EXPENSE, + entryType = "expense", + fundSourceType = "stored_value", + fundSourceName = null, + fundSourceNameFromChannel = true, + fundTargetType = "merchant", + fundTargetName = null, + fundTargetNameFromChannel = false, + impactExpense = true, + impactIncome = false, defaultCategory = "Transport", autoCapture = true, merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"), diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt index f9113c2..4c1e9da 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt @@ -14,6 +14,15 @@ internal data class NotificationRuleDefinition( val keywords: List = emptyList(), val requiredTextPatterns: List = emptyList(), val direction: NotificationRuleDirection, + val entryType: String = "expense", + val fundSourceType: String = "cash", + val fundSourceName: String? = null, + val fundSourceNameFromChannel: Boolean = false, + val fundTargetType: String = "merchant", + val fundTargetName: String? = null, + val fundTargetNameFromChannel: Boolean = false, + val impactExpense: Boolean = true, + val impactIncome: Boolean = false, val defaultCategory: String, val autoCapture: Boolean, val merchantPattern: Regex? = null, @@ -30,6 +39,13 @@ object NotificationRuleEngine { val category: String, val amount: Double, val isExpense: Boolean, + val entryType: String, + val fundSourceType: String, + val fundSourceName: String, + val fundTargetType: String, + val fundTargetName: String, + val impactExpense: Boolean, + val impactIncome: Boolean, val note: String?, val autoCapture: Boolean, ) @@ -60,11 +76,24 @@ object NotificationRuleEngine { category = rule.defaultCategory.ifBlank { "Uncategorized" }, amount = amount, isExpense = rule.direction == NotificationRuleDirection.EXPENSE, + entryType = rule.entryType, + fundSourceType = rule.fundSourceType, + fundSourceName = resolveLedgerName(raw.channel, rule.fundSourceName, rule.fundSourceNameFromChannel), + fundTargetType = rule.fundTargetType, + fundTargetName = resolveLedgerName(raw.channel, rule.fundTargetName, rule.fundTargetNameFromChannel) + .ifBlank { if (rule.entryType == "expense") merchant else "" }, + impactExpense = rule.impactExpense, + impactIncome = rule.impactIncome, note = content.take(200), autoCapture = rule.autoCapture, ) } + private fun resolveLedgerName(channel: String?, configuredName: String?, fromChannel: Boolean): String { + if (fromChannel && !channel.isNullOrBlank()) return channel + return configuredName.orEmpty() + } + private fun matchRule( raw: NotificationDb.RawNotification, text: String, @@ -80,17 +109,25 @@ object NotificationRuleEngine { val keywordHit = rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) } val requiredTextHit = - rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) } + rule.requiredTextPatterns.isEmpty() || + rule.requiredTextPatterns.any { text.contains(it) } || + hasTransitRouteAmountFallback(rule, text) return channelHit && keywordHit && requiredTextHit } + private fun hasTransitRouteAmountFallback(rule: NotificationRuleDefinition, text: String): Boolean { + if (rule.id != "transit-card-expense") return false + return Regex("""[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)""") + .containsMatchIn(text) + } + private fun extractAmount(text: String): Double { val normalized = text.replace(",", "").trim() val patterns = listOf( Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE), Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""), - Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""), + Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""), Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""), ) diff --git a/scripts/rule-smoke.mjs b/scripts/rule-smoke.mjs index 79375e0..a789c0a 100644 --- a/scripts/rule-smoke.mjs +++ b/scripts/rule-smoke.mjs @@ -25,6 +25,56 @@ const cases = [ verify(result) { assert.equal(result.rule?.id, 'transit-card-expense') assert.equal(result.transaction.amount, -2) + assert.equal(result.transaction.fundSourceType, 'stored_value') + assert.equal(result.transaction.impactExpense, true) + }, + }, + { + name: 'capture transit expense from route and fare only', + notification: { + id: 'n2b', + channel: '杭州通互联互通卡', + text: '武林广场->古荡:2.10元', + createdAt: '2026-03-11T00:00:00.000Z', + }, + verify(result) { + assert.equal(result.rule?.id, 'transit-card-expense') + assert.equal(result.transaction.amount, -2.1) + assert.equal(result.transaction.category, 'Transport') + assert.equal(result.transaction.fundSourceName, '杭州通互联互通卡') + }, + }, + { + name: 'capture stored value topup as transfer', + notification: { + id: 'n2c', + channel: '杭州通互联互通卡', + text: '充值成功20元', + createdAt: '2026-03-11T00:00:00.000Z', + }, + verify(result) { + assert.equal(result.rule?.id, 'stored-value-topup') + assert.equal(result.transaction.entryType, 'transfer') + assert.equal(result.transaction.category, 'Transfer') + assert.equal(result.transaction.amount, -20) + assert.equal(result.transaction.impactExpense, false) + assert.equal(result.transaction.fundTargetName, '杭州通互联互通卡') + }, + }, + { + name: 'capture stored value refund as transfer', + notification: { + id: 'n2d', + channel: '杭州通互联互通卡', + text: '退款成功5元', + createdAt: '2026-03-11T00:00:00.000Z', + }, + verify(result) { + assert.equal(result.rule?.id, 'stored-value-refund') + assert.equal(result.transaction.entryType, 'transfer') + assert.equal(result.transaction.amount, 5) + assert.equal(result.transaction.impactIncome, false) + assert.equal(result.transaction.fundSourceName, '杭州通互联互通卡') }, }, { diff --git a/scripts/sync-notification-rules.mjs b/scripts/sync-notification-rules.mjs index ae1bc52..331b54e 100644 --- a/scripts/sync-notification-rules.mjs +++ b/scripts/sync-notification-rules.mjs @@ -1,4 +1,4 @@ -import fs from 'node:fs' +import fs from 'node:fs' import path from 'node:path' import notificationRules from '../src/config/notificationRules.js' @@ -28,6 +28,8 @@ const toKotlinRegex = (spec) => { return `Regex(${toKotlinString(spec.pattern)}${flags})` } +const toOptionalString = (value) => (value ? toKotlinString(value) : 'null') + const toRuleBlock = (rule) => ` NotificationRuleDefinition( id = ${toKotlinString(rule.id)}, label = ${toKotlinString(rule.label)}, @@ -35,12 +37,19 @@ const toRuleBlock = (rule) => ` NotificationRuleDefinition( keywords = ${toKotlinStringList(rule.keywords)}, requiredTextPatterns = ${toKotlinStringList(rule.requiredTextPatterns)}, direction = NotificationRuleDirection.${rule.direction === 'income' ? 'INCOME' : 'EXPENSE'}, + entryType = ${toKotlinString(rule.entryType || 'expense')}, + fundSourceType = ${toKotlinString(rule.fundSourceType || 'cash')}, + fundSourceName = ${toOptionalString(rule.fundSourceName)}, + fundSourceNameFromChannel = ${rule.fundSourceNameFromChannel ? 'true' : 'false'}, + fundTargetType = ${toKotlinString(rule.fundTargetType || 'merchant')}, + fundTargetName = ${toOptionalString(rule.fundTargetName)}, + fundTargetNameFromChannel = ${rule.fundTargetNameFromChannel ? 'true' : 'false'}, + impactExpense = ${rule.impactExpense === false ? 'false' : 'true'}, + impactIncome = ${rule.impactIncome ? 'true' : 'false'}, defaultCategory = ${toKotlinString(rule.defaultCategory)}, autoCapture = ${rule.autoCapture ? 'true' : 'false'}, merchantPattern = ${toKotlinRegex(rule.merchantPattern)}, - defaultMerchant = ${ - rule.defaultMerchant ? toKotlinString(rule.defaultMerchant) : 'null' - }, + defaultMerchant = ${toOptionalString(rule.defaultMerchant)}, )` const content = `package com.echo.app.notification diff --git a/src/config/aiStatus.js b/src/config/aiStatus.js new file mode 100644 index 0000000..2b25c21 --- /dev/null +++ b/src/config/aiStatus.js @@ -0,0 +1,28 @@ +export const AI_STATUS_META = { + running: { + label: 'AI 分类中', + icon: 'ph-circle-notch', + className: 'bg-amber-50 text-amber-700 border-amber-200', + iconClassName: 'animate-spin', + }, + applied: { + label: 'AI 已补全', + icon: 'ph-sparkle', + className: 'bg-violet-50 text-violet-700 border-violet-200', + iconClassName: '', + }, + suggested: { + label: 'AI 建议', + icon: 'ph-lightbulb', + className: 'bg-sky-50 text-sky-700 border-sky-200', + iconClassName: '', + }, + failed: { + label: 'AI 失败', + icon: 'ph-warning', + className: 'bg-stone-100 text-stone-500 border-stone-200', + iconClassName: '', + }, +} + +export const getAiStatusMeta = (status) => AI_STATUS_META[status] || null diff --git a/src/config/ledger.js b/src/config/ledger.js new file mode 100644 index 0000000..8f1ce5d --- /dev/null +++ b/src/config/ledger.js @@ -0,0 +1,76 @@ +export const TRANSACTION_ENTRY_TYPES = [ + { value: 'expense', label: '支出' }, + { value: 'income', label: '收入' }, + { value: 'transfer', label: '转账' }, +] + +export const ACCOUNT_TYPE_META = { + cash: { label: '现金账户' }, + bank: { label: '银行卡' }, + credit: { label: '信用账户' }, + stored_value: { label: '储值账户' }, + merchant: { label: '商户' }, + income_source: { label: '收入来源' }, + external: { label: '外部账户' }, + unknown: { label: '未分类账户' }, +} + +export const STORED_VALUE_WALLET_KINDS = [ + { value: 'transit', label: '交通卡 / 乘车码' }, + { value: 'campus', label: '校园卡' }, + { value: 'meal', label: '饭卡' }, + { value: 'parking', label: '停车卡' }, + { value: 'fuel', label: '加油卡' }, + { value: 'toll', label: '通行卡' }, + { value: 'gift', label: '礼品卡 / 会员储值' }, + { value: 'other', label: '其他储值' }, +] + +export const DEFAULT_LEDGER_BY_ENTRY_TYPE = { + expense: { + entryType: 'expense', + fundSourceType: 'cash', + fundSourceName: '现金账户', + fundTargetType: 'merchant', + fundTargetName: '', + impactExpense: true, + impactIncome: false, + }, + income: { + entryType: 'income', + fundSourceType: 'income_source', + fundSourceName: '收入来源', + fundTargetType: 'cash', + fundTargetName: '现金账户', + impactExpense: false, + impactIncome: true, + }, + transfer: { + entryType: 'transfer', + fundSourceType: 'cash', + fundSourceName: '现金账户', + fundTargetType: 'stored_value', + fundTargetName: '储值账户', + impactExpense: false, + impactIncome: false, + }, +} + +export const getEntryTypeLabel = (value) => + TRANSACTION_ENTRY_TYPES.find((item) => item.value === value)?.label || '记录' + +export const getAccountTypeLabel = (value) => ACCOUNT_TYPE_META[value]?.label || ACCOUNT_TYPE_META.unknown.label + +export const isStoredValueAccountType = (value) => value === 'stored_value' + +export const shouldCountAsExpense = (transaction) => + transaction?.impactExpense !== false && transaction?.entryType !== 'transfer' && Number(transaction?.amount) < 0 + +export const shouldCountAsIncome = (transaction) => + transaction?.impactIncome !== false && transaction?.entryType !== 'transfer' && Number(transaction?.amount) > 0 + +export const getTransferSummary = (transaction) => { + const source = transaction?.fundSourceName || getAccountTypeLabel(transaction?.fundSourceType) + const target = transaction?.fundTargetName || getAccountTypeLabel(transaction?.fundTargetType) + return `${source} → ${target}` +} diff --git a/src/config/notificationRules.js b/src/config/notificationRules.js index 9112168..2eccdaa 100644 --- a/src/config/notificationRules.js +++ b/src/config/notificationRules.js @@ -1,4 +1,22 @@ -const notificationRules = [ +const transitStoredValueChannels = [ + '杭州通互联互通卡', + '杭州通', + '北京一卡通', + '上海公共交通卡', + '深圳通', + '苏州公交卡', + '交通联合', + '乘车码', + '地铁', + '公交', + 'Mi Pay', + 'Huawei Pay', + '华为钱包', + '小米钱包', + 'OPPO 钱包', +] + +const notificationRules = [ { id: 'alipay-expense', label: '支付宝消费', @@ -6,6 +24,13 @@ keywords: ['支付', '扣款', '支出', '消费', '成功支付'], requiredTextPatterns: [], direction: 'expense', + entryType: 'expense', + fundSourceType: 'cash', + fundSourceName: '支付宝余额', + fundTargetType: 'merchant', + fundTargetName: '', + impactExpense: true, + impactIncome: false, defaultCategory: 'Food', autoCapture: false, merchantPattern: { @@ -21,6 +46,13 @@ keywords: ['到账', '收入', '收款', '入账'], requiredTextPatterns: [], direction: 'income', + entryType: 'income', + fundSourceType: 'external', + fundSourceName: '外部转入', + fundTargetType: 'cash', + fundTargetName: '支付宝余额', + impactExpense: false, + impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { @@ -36,6 +68,13 @@ keywords: ['支付', '支出', '扣款', '消费成功'], requiredTextPatterns: [], direction: 'expense', + entryType: 'expense', + fundSourceType: 'cash', + fundSourceName: '微信余额', + fundTargetType: 'merchant', + fundTargetName: '', + impactExpense: true, + impactIncome: false, defaultCategory: 'Groceries', autoCapture: false, merchantPattern: { @@ -51,6 +90,13 @@ keywords: ['收款', '到账', '入账', '转入'], requiredTextPatterns: [], direction: 'income', + entryType: 'income', + fundSourceType: 'external', + fundSourceName: '外部转入', + fundTargetType: 'cash', + fundTargetName: '微信余额', + impactExpense: false, + impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { @@ -79,6 +125,13 @@ keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'], requiredTextPatterns: [], direction: 'income', + entryType: 'income', + fundSourceType: 'external', + fundSourceName: '外部转入', + fundTargetType: 'bank', + fundTargetName: '银行卡', + impactExpense: false, + impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { @@ -109,6 +162,13 @@ keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'], requiredTextPatterns: [], direction: 'expense', + entryType: 'expense', + fundSourceType: 'bank', + fundSourceName: '银行卡', + fundTargetType: 'merchant', + fundTargetName: '', + impactExpense: true, + impactIncome: false, defaultCategory: 'Expense', autoCapture: false, merchantPattern: { @@ -117,29 +177,61 @@ }, defaultMerchant: '', }, + { + id: 'stored-value-topup', + label: '储值账户充值', + channels: transitStoredValueChannels, + keywords: ['充值', '充值成功', '充值到账', '钱包充值', '卡内充值'], + requiredTextPatterns: [], + direction: 'expense', + entryType: 'transfer', + fundSourceType: 'cash', + fundSourceName: '现金账户', + fundTargetType: 'stored_value', + fundTargetName: '', + fundTargetNameFromChannel: true, + impactExpense: false, + impactIncome: false, + defaultCategory: 'Transfer', + autoCapture: true, + merchantPattern: null, + defaultMerchant: '储值账户充值', + }, + { + id: 'stored-value-refund', + label: '储值账户退款', + channels: transitStoredValueChannels, + keywords: ['退款', '退资', '退卡', '余额退回', '退款成功'], + requiredTextPatterns: [], + direction: 'income', + entryType: 'transfer', + fundSourceType: 'stored_value', + fundSourceName: '', + fundSourceNameFromChannel: true, + fundTargetType: 'cash', + fundTargetName: '现金账户', + impactExpense: false, + impactIncome: false, + defaultCategory: 'Transfer', + autoCapture: true, + merchantPattern: null, + defaultMerchant: '储值账户退款', + }, { id: 'transit-card-expense', label: '公交出行', - channels: [ - '杭州通互联互通卡', - '杭州通', - '北京一卡通', - '上海公共交通卡', - '深圳通', - '苏州公交卡', - '交通联合', - '乘车码', - '地铁', - '公交', - 'Mi Pay', - 'Huawei Pay', - '华为钱包', - '小米钱包', - 'OPPO 钱包', - ], + channels: transitStoredValueChannels, keywords: [], requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'], direction: 'expense', + entryType: 'expense', + fundSourceType: 'stored_value', + fundSourceName: '', + fundSourceNameFromChannel: true, + fundTargetType: 'merchant', + fundTargetName: '', + impactExpense: true, + impactIncome: false, defaultCategory: 'Transport', autoCapture: true, merchantPattern: { diff --git a/src/config/transactionCategories.js b/src/config/transactionCategories.js index 56e6087..3422df6 100644 --- a/src/config/transactionCategories.js +++ b/src/config/transactionCategories.js @@ -47,6 +47,14 @@ color: 'text-stone-600', budgetable: true, }, + { + value: 'Transfer', + label: '转账', + icon: 'ph-arrows-left-right', + bg: 'bg-cyan-100', + color: 'text-cyan-700', + budgetable: false, + }, { value: 'Income', label: '收入', @@ -84,7 +92,6 @@ export const DEFAULT_CATEGORY_BUDGETS = BUDGET_CATEGORY_OPTIONS.reduce((acc, cat return acc }, {}) -export const getCategoryMeta = (category) => - CATEGORY_META[category] || CATEGORY_META.Uncategorized +export const getCategoryMeta = (category) => CATEGORY_META[category] || CATEGORY_META.Uncategorized export const getCategoryLabel = (category) => getCategoryMeta(category).label diff --git a/src/lib/aiPrompt.js b/src/lib/aiPrompt.js index f8a543a..3a9ed3a 100644 --- a/src/lib/aiPrompt.js +++ b/src/lib/aiPrompt.js @@ -1,8 +1,92 @@ -import { TRANSACTION_CATEGORIES } from '../config/transactionCategories.js' +import { + getCategoryLabel, + TRANSACTION_CATEGORIES, +} from '../config/transactionCategories.js' +import { + getTransferSummary, + shouldCountAsExpense, + shouldCountAsIncome, +} from '../config/ledger.js' import { AI_TRANSACTION_TAGS } from '../config/transactionTags.js' const categoryValues = TRANSACTION_CATEGORIES.map((item) => item.value) +const formatAmount = (value) => Number(value || 0).toFixed(2) + +const buildExpenseCategoryBreakdown = (transactions) => { + const categoryMap = new Map() + + transactions.forEach((transaction) => { + if (!shouldCountAsExpense(transaction)) return + const category = transaction.category || 'Uncategorized' + const nextTotal = (categoryMap.get(category) || 0) + Math.abs(Number(transaction.amount) || 0) + categoryMap.set(category, nextTotal) + }) + + return Array.from(categoryMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([category, total]) => ({ + category, + label: getCategoryLabel(category), + total: Number(total.toFixed(2)), + })) +} + +const buildTransferSample = (transactions) => + transactions + .filter((transaction) => transaction.entryType === 'transfer') + .slice(0, 10) + .map((transaction) => ({ + date: transaction.date, + amount: Number(transaction.amount || 0), + summary: getTransferSummary(transaction), + merchant: transaction.merchant || '', + })) + +const buildRecentTransactionSample = (transactions) => + transactions.slice(0, 20).map((transaction) => ({ + date: transaction.date, + merchant: transaction.merchant || 'Unknown', + amount: Number(transaction.amount || 0), + category: transaction.category || 'Uncategorized', + categoryLabel: getCategoryLabel(transaction.category || 'Uncategorized'), + note: transaction.note || '', + entryType: transaction.entryType || (transaction.amount >= 0 ? 'income' : 'expense'), + transferSummary: transaction.entryType === 'transfer' ? getTransferSummary(transaction) : '', + aiCategory: transaction.aiCategory || '', + aiTags: transaction.aiTags || [], + })) + +const buildLedgerSummary = (transactions = []) => { + const normalizedTransactions = [...transactions] + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .slice(0, 80) + + const totalIncome = normalizedTransactions.reduce( + (sum, item) => (shouldCountAsIncome(item) ? sum + Number(item.amount || 0) : sum), + 0, + ) + const totalExpense = normalizedTransactions.reduce( + (sum, item) => (shouldCountAsExpense(item) ? sum + Math.abs(Number(item.amount || 0)) : sum), + 0, + ) + + return { + currentDate: new Date().toISOString(), + transactionCount: normalizedTransactions.length, + expenseTransactionCount: normalizedTransactions.filter((item) => shouldCountAsExpense(item)).length, + incomeTransactionCount: normalizedTransactions.filter((item) => shouldCountAsIncome(item)).length, + transferTransactionCount: normalizedTransactions.filter((item) => item.entryType === 'transfer').length, + totalIncome: Number(formatAmount(totalIncome)), + totalExpense: Number(formatAmount(totalExpense)), + netBalance: Number(formatAmount(totalIncome - totalExpense)), + topExpenseCategories: buildExpenseCategoryBreakdown(normalizedTransactions), + recentTransfers: buildTransferSample(normalizedTransactions), + recentTransactions: buildRecentTransactionSample(normalizedTransactions), + } +} + export const buildTransactionEnrichmentMessages = ({ transaction, similarTransactions = [] }) => { const payload = { transaction: { @@ -14,7 +98,7 @@ export const buildTransactionEnrichmentMessages = ({ transaction, similarTransac currentCategory: transaction.category || 'Uncategorized', date: transaction.date, }, - categories: categoryValues, + categories: categoryValues.filter((value) => value !== 'Transfer'), tags: AI_TRANSACTION_TAGS, similarTransactions: similarTransactions.slice(0, 6).map((item) => ({ merchant: item.aiNormalizedMerchant || item.merchant, @@ -37,9 +121,39 @@ export const buildTransactionEnrichmentMessages = ({ transaction, similarTransac ] } +export const buildFinanceChatMessages = ({ question, transactions = [], conversation = [] }) => { + const summary = buildLedgerSummary(transactions) + const history = conversation + .filter((item) => ['user', 'assistant'].includes(item.role) && item.content) + .slice(-6) + .map((item) => ({ + role: item.role, + content: item.content, + })) + + return [ + { + role: 'system', + content: + '你是 Echo 记账应用里的 AI 财务顾问。你只能基于用户提供的本地账本摘要回答问题,不能编造不存在的交易。回答使用简体中文,先给结论,再给理由和建议。金额统一写成人民币元。充值储值卡、卡间转账等内部转移不算消费支出,分析时要和真实消费分开。若数据不足,要明确说明。', + }, + { + role: 'system', + content: `以下是用户本地账本摘要 JSON:${JSON.stringify(summary)}`, + }, + ...history, + { + role: 'user', + content: question, + }, + ] +} + export const normalizeEnrichmentResult = (rawResult) => { const result = rawResult && typeof rawResult === 'object' ? rawResult : {} - const category = categoryValues.includes(result.category) ? result.category : 'Uncategorized' + const category = categoryValues.includes(result.category) && result.category !== 'Transfer' + ? result.category + : 'Uncategorized' const tags = Array.isArray(result.tags) ? result.tags.filter((tag) => AI_TRANSACTION_TAGS.includes(tag)).slice(0, 3) : [] diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js index 72b0dfd..4183589 100644 --- a/src/lib/sqlite.js +++ b/src/lib/sqlite.js @@ -13,6 +13,13 @@ const TRANSACTION_TABLE_SQL = ` date TEXT NOT NULL, note TEXT, sync_status INTEGER DEFAULT 0, + entry_type TEXT DEFAULT 'expense', + fund_source_type TEXT DEFAULT 'cash', + fund_source_name TEXT, + fund_target_type TEXT DEFAULT 'merchant', + fund_target_name TEXT, + impact_expense INTEGER DEFAULT 1, + impact_income INTEGER DEFAULT 0, ai_category TEXT, ai_tags TEXT, ai_confidence REAL, @@ -33,6 +40,13 @@ const MERCHANT_PROFILE_TABLE_SQL = ` ); ` const TRANSACTION_REQUIRED_COLUMNS = { + entry_type: "TEXT DEFAULT 'expense'", + fund_source_type: "TEXT DEFAULT 'cash'", + fund_source_name: 'TEXT', + fund_target_type: "TEXT DEFAULT 'merchant'", + fund_target_name: 'TEXT', + impact_expense: 'INTEGER DEFAULT 1', + impact_income: 'INTEGER DEFAULT 0', ai_category: 'TEXT', ai_tags: 'TEXT', ai_confidence: 'REAL', diff --git a/src/services/ai/aiService.js b/src/services/ai/aiService.js index 8057db0..cb4ba01 100644 --- a/src/services/ai/aiService.js +++ b/src/services/ai/aiService.js @@ -1,9 +1,13 @@ -import { buildTransactionEnrichmentMessages } from '../../lib/aiPrompt.js' +import { + buildFinanceChatMessages, + buildTransactionEnrichmentMessages, +} from '../../lib/aiPrompt.js' import { useSettingsStore } from '../../stores/settings.js' import { applyAiEnrichment, fetchRecentTransactionsForAi, markTransactionAiFailed, + markTransactionAiRunning, } from '../transactionService.js' import { DeepSeekProvider } from './deepseekProvider.js' @@ -24,11 +28,13 @@ const createProvider = (settingsStore) => { }) } -export const isAiReady = (settingsStore = useSettingsStore()) => - !!settingsStore.aiAutoCategoryEnabled && !!settingsStore.aiApiKey +export const hasAiAccess = (settingsStore = useSettingsStore()) => !!settingsStore.aiApiKey -export const maybeEnrichTransactionWithAi = async (transaction) => { - if (!transaction?.id) return transaction +export const isAiReady = (settingsStore = useSettingsStore()) => + !!settingsStore.aiAutoCategoryEnabled && hasAiAccess(settingsStore) + +export const maybeEnrichTransactionWithAi = async (transaction, options = {}) => { + if (!transaction?.id || transaction.entryType === 'transfer') return transaction const settingsStore = useSettingsStore() if (!isAiReady(settingsStore)) { @@ -36,11 +42,16 @@ export const maybeEnrichTransactionWithAi = async (transaction) => { } try { + const running = await markTransactionAiRunning(transaction.id, settingsStore.aiModel) + if (running) { + options.onProgress?.(running) + } + const provider = createProvider(settingsStore) const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id) const messages = buildTransactionEnrichmentMessages({ transaction, - similarTransactions, + similarTransactions: similarTransactions.filter((item) => item.entryType !== 'transfer'), }) const result = await provider.enrichTransaction({ messages }) const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold) @@ -61,3 +72,19 @@ export const maybeEnrichTransactionWithAi = async (transaction) => { return failed || transaction } } + +export const askFinanceAssistant = async ({ question, transactions = [], conversation = [] }) => { + const settingsStore = useSettingsStore() + if (!hasAiAccess(settingsStore)) { + throw new Error('请先在设置页填写 DeepSeek API Key') + } + + const provider = createProvider(settingsStore) + const messages = buildFinanceChatMessages({ + question, + transactions, + conversation, + }) + + return provider.chat({ messages }) +} diff --git a/src/services/ai/deepseekProvider.js b/src/services/ai/deepseekProvider.js index 1e74e5a..c5fcf2e 100644 --- a/src/services/ai/deepseekProvider.js +++ b/src/services/ai/deepseekProvider.js @@ -1,6 +1,17 @@ import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js' import { AiProvider } from './aiProvider.js' +const extractMessageContent = (data) => { + const content = data?.choices?.[0]?.message?.content + if (Array.isArray(content)) { + return content + .map((item) => (typeof item?.text === 'string' ? item.text : '')) + .join('') + .trim() + } + return typeof content === 'string' ? content.trim() : '' +} + export class DeepSeekProvider extends AiProvider { constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) { super() @@ -9,7 +20,7 @@ export class DeepSeekProvider extends AiProvider { this.model = model } - async enrichTransaction({ messages }) { + async createCompletion(payload) { if (!this.apiKey) { throw new Error('DeepSeek API key is required') } @@ -22,9 +33,7 @@ export class DeepSeekProvider extends AiProvider { }, body: JSON.stringify({ model: this.model, - temperature: 0.2, - response_format: { type: 'json_object' }, - messages, + ...payload, }), }) @@ -33,8 +42,17 @@ export class DeepSeekProvider extends AiProvider { throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim()) } - const data = await response.json() - const content = data?.choices?.[0]?.message?.content + return response.json() + } + + async enrichTransaction({ messages }) { + const data = await this.createCompletion({ + temperature: 0.2, + response_format: { type: 'json_object' }, + messages, + }) + + const content = extractMessageContent(data) if (!content) { throw new Error('DeepSeek returned an empty response') } @@ -52,4 +70,22 @@ export class DeepSeekProvider extends AiProvider { model: data?.model || this.model, } } + + async chat({ messages, temperature = 0.6, maxTokens = 1200 }) { + const data = await this.createCompletion({ + temperature, + max_tokens: maxTokens, + messages, + }) + + const content = extractMessageContent(data) + if (!content) { + throw new Error('DeepSeek returned an empty response') + } + + return { + content, + model: data?.model || this.model, + } + } } diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js index ae8957c..24bb935 100644 --- a/src/services/notificationRuleService.js +++ b/src/services/notificationRuleService.js @@ -1,4 +1,5 @@ -import notificationRulesSource from '../config/notificationRules.js' +import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js' +import notificationRulesSource from '../config/notificationRules.js' const fallbackRules = notificationRulesSource.map((rule) => ({ ...rule, @@ -10,13 +11,36 @@ const fallbackRules = notificationRulesSource.map((rule) => ({ const amountPatterns = [ /(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i, /(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/, - /(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/, + /(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/, /(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/, ] let cachedRules = [...fallbackRules] let remoteRuleFetcher = async () => [] +const buildLedgerPayload = (rule, notification, merchant) => { + const entryType = rule?.entryType || 'expense' + const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense + const channelName = notification?.channel || '' + + const resolveName = (field, fromChannelFlag) => { + if (rule?.[fromChannelFlag] && channelName) return channelName + return rule?.[field] || defaults[field] || '' + } + + return { + entryType, + fundSourceType: rule?.fundSourceType || defaults.fundSourceType, + fundSourceName: resolveName('fundSourceName', 'fundSourceNameFromChannel'), + fundTargetType: rule?.fundTargetType || defaults.fundTargetType, + fundTargetName: + resolveName('fundTargetName', 'fundTargetNameFromChannel') || + (entryType === 'expense' ? merchant : defaults.fundTargetName), + impactExpense: rule?.impactExpense ?? defaults.impactExpense, + impactIncome: rule?.impactIncome ?? defaults.impactIncome, + } +} + const buildEmptyTransaction = (notification, options = {}) => ({ id: options.id, merchant: options.merchant || 'Unknown', @@ -25,6 +49,7 @@ const buildEmptyTransaction = (notification, options = {}) => ({ date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(), note: notification?.text || '', syncStatus: 'pending', + ...DEFAULT_LEDGER_BY_ENTRY_TYPE.expense, }) export const setRemoteRuleFetcher = (fetcher) => { @@ -78,6 +103,13 @@ const looksLikeAmount = (raw = '') => { return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value) } +const hasTransitRouteAmountFallback = (rule, text = '') => { + if (rule?.id !== 'transit-card-expense') return false + return /[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)/.test( + text, + ) +} + const extractMerchant = (text, rule) => { const content = text || '' @@ -134,7 +166,8 @@ const matchRule = (notification, rule) => { const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word)) const requiredTextHit = !rule.requiredTextPatterns?.length || - rule.requiredTextPatterns.some((word) => text.includes(word)) + rule.requiredTextPatterns.some((word) => text.includes(word)) || + hasTransitRouteAmountFallback(rule, text) return channelHit && keywordHit && requiredTextHit } @@ -161,8 +194,7 @@ export const transformNotificationToTransaction = (notification, options = {}) = } const direction = options.direction || rule.direction || 'expense' - const normalizedAmount = - direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) + const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' if (merchant === 'Unknown' && rule.defaultMerchant) { @@ -170,6 +202,7 @@ export const transformNotificationToTransaction = (notification, options = {}) = } const date = options.date || notification?.createdAt || new Date().toISOString() + const ledger = buildLedgerPayload(rule, notification, merchant) const transaction = { id: options.id, @@ -179,6 +212,7 @@ export const transformNotificationToTransaction = (notification, options = {}) = date: new Date(date).toISOString(), note: notification?.text || '', syncStatus: 'pending', + ...ledger, } const requiresConfirmation = diff --git a/src/services/transactionService.js b/src/services/transactionService.js index bc08758..d7954c0 100644 --- a/src/services/transactionService.js +++ b/src/services/transactionService.js @@ -1,4 +1,8 @@ -import { v4 as uuidv4 } from 'uuid' +import { v4 as uuidv4 } from 'uuid' +import { + DEFAULT_LEDGER_BY_ENTRY_TYPE, + getAccountTypeLabel, +} from '../config/ledger.js' import { getDb, saveDbToStore } from '../lib/sqlite' const statusMap = { @@ -15,6 +19,13 @@ const TRANSACTION_SELECT_FIELDS = ` date, note, sync_status, + entry_type, + fund_source_type, + fund_source_name, + fund_target_type, + fund_target_name, + impact_expense, + impact_income, ai_category, ai_tags, ai_confidence, @@ -31,7 +42,7 @@ const normalizeStatus = (value) => { } const normalizeAiStatus = (value) => { - if (['applied', 'suggested', 'failed'].includes(value)) { + if (['running', 'applied', 'suggested', 'failed'].includes(value)) { return value } return 'idle' @@ -47,22 +58,68 @@ const parseAiTags = (value) => { } } -const mapRow = (row) => ({ - id: row.id, - amount: Number(row.amount), - merchant: row.merchant, - category: row.category || 'Uncategorized', - date: row.date, - note: row.note || '', - syncStatus: normalizeStatus(row.sync_status), - aiCategory: row.ai_category || '', - aiTags: parseAiTags(row.ai_tags), - aiConfidence: typeof row.ai_confidence === 'number' ? row.ai_confidence : Number(row.ai_confidence || 0), - aiReason: row.ai_reason || '', - aiStatus: normalizeAiStatus(row.ai_status), - aiModel: row.ai_model || '', - aiNormalizedMerchant: row.ai_normalized_merchant || '', -}) +const normalizeFlag = (value, fallback = false) => { + if (value === 1 || value === true || value === '1') return true + if (value === 0 || value === false || value === '0') return false + return fallback +} + +const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense') + +const buildLedgerDefaults = (payload = {}, existing = null) => { + const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0) + const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense + + return { + entryType, + fundSourceType: payload.fundSourceType || existing?.fundSourceType || defaults.fundSourceType, + fundSourceName: + payload.fundSourceName || + existing?.fundSourceName || + defaults.fundSourceName || + getAccountTypeLabel(defaults.fundSourceType), + fundTargetType: payload.fundTargetType || existing?.fundTargetType || defaults.fundTargetType, + fundTargetName: + payload.fundTargetName || + existing?.fundTargetName || + defaults.fundTargetName || + getAccountTypeLabel(defaults.fundTargetType), + impactExpense: + payload.impactExpense ?? existing?.impactExpense ?? defaults.impactExpense ?? entryType === 'expense', + impactIncome: + payload.impactIncome ?? existing?.impactIncome ?? defaults.impactIncome ?? entryType === 'income', + } +} + +const mapRow = (row) => { + const aiConfidenceValue = Number(row.ai_confidence) + const entryType = row.entry_type || inferEntryTypeFromAmount(row.amount) + const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense + + return { + id: row.id, + amount: Number(row.amount), + merchant: row.merchant, + category: row.category || 'Uncategorized', + date: row.date, + note: row.note || '', + syncStatus: normalizeStatus(row.sync_status), + entryType, + fundSourceType: row.fund_source_type || defaults.fundSourceType, + fundSourceName: row.fund_source_name || defaults.fundSourceName || '', + fundTargetType: row.fund_target_type || defaults.fundTargetType, + fundTargetName: row.fund_target_name || defaults.fundTargetName || '', + impactExpense: normalizeFlag(row.impact_expense, defaults.impactExpense), + impactIncome: normalizeFlag(row.impact_income, defaults.impactIncome), + aiCategory: row.ai_category || '', + aiTags: parseAiTags(row.ai_tags), + aiConfidence: Number.isFinite(aiConfidenceValue) ? aiConfidenceValue : 0, + aiReason: row.ai_reason || '', + aiStatus: normalizeAiStatus(row.ai_status), + aiModel: row.ai_model || '', + aiNormalizedMerchant: row.ai_normalized_merchant || '', + } +} const getMerchantKey = (merchant) => String(merchant || '') @@ -190,19 +247,37 @@ export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') => export const insertTransaction = async (payload) => { const db = await getDb() const id = payload.id || uuidv4() + const amount = Number(payload.amount) + const ledger = buildLedgerDefaults(payload) const sanitized = { merchant: payload.merchant?.trim() || 'Unknown', category: payload.category?.trim() || 'Uncategorized', note: payload.note?.trim() || '', date: payload.date, - amount: Number(payload.amount), + amount, syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending, + ...ledger, } await db.run( ` - INSERT INTO transactions (id, amount, merchant, category, date, note, sync_status) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO transactions ( + id, + amount, + merchant, + category, + date, + note, + sync_status, + entry_type, + fund_source_type, + fund_source_name, + fund_target_type, + fund_target_name, + impact_expense, + impact_income + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, @@ -212,6 +287,13 @@ export const insertTransaction = async (payload) => { sanitized.date, sanitized.note || null, sanitized.syncStatus, + sanitized.entryType, + sanitized.fundSourceType, + sanitized.fundSourceName || null, + sanitized.fundTargetType, + sanitized.fundTargetName || null, + sanitized.impactExpense ? 1 : 0, + sanitized.impactIncome ? 1 : 0, ], ) @@ -232,20 +314,31 @@ export const insertTransaction = async (payload) => { } export const updateTransaction = async (payload) => { + const existing = await getTransactionById(payload.id) + if (!existing) return null + const db = await getDb() + const ledger = buildLedgerDefaults(payload, existing) await db.run( ` UPDATE transactions - SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ? + SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ? WHERE id = ? `, [ Number(payload.amount), - payload.merchant?.trim() || 'Unknown', - payload.category?.trim() || 'Uncategorized', + payload.merchant?.trim() || existing.merchant || 'Unknown', + payload.category?.trim() || existing.category || 'Uncategorized', payload.date, payload.note?.trim() || null, statusMap[payload.syncStatus] ?? statusMap.pending, + ledger.entryType, + ledger.fundSourceType, + ledger.fundSourceName || null, + ledger.fundTargetType, + ledger.fundTargetName || null, + ledger.impactExpense ? 1 : 0, + ledger.impactIncome ? 1 : 0, payload.id, ], ) @@ -254,6 +347,21 @@ export const updateTransaction = async (payload) => { return getTransactionById(payload.id) } +export const markTransactionAiRunning = async (transactionId, model = '') => { + const db = await getDb() + await db.run( + ` + UPDATE transactions + SET ai_status = ?, ai_model = ?, ai_reason = ? + WHERE id = ? + `, + ['running', model || null, null, transactionId], + ) + + await saveDbToStore() + return getTransactionById(transactionId) +} + export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => { const existing = await getTransactionById(transactionId) if (!existing) return null @@ -282,7 +390,10 @@ export const applyAiEnrichment = async (transactionId, enrichment, options = {}) ], ) - if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) { + if ( + !options.skipMerchantProfile && + (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length) + ) { await upsertMerchantProfile({ merchant: existing.merchant, normalizedMerchant: enrichment.normalizedMerchant, diff --git a/src/stores/transactions.js b/src/stores/transactions.js index 1688beb..fe52788 100644 --- a/src/stores/transactions.js +++ b/src/stores/transactions.js @@ -1,5 +1,6 @@ -import { defineStore } from 'pinia' +import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { shouldCountAsExpense, shouldCountAsIncome } from '../config/ledger.js' import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js' import { deleteTransaction, @@ -52,19 +53,19 @@ export const useTransactionStore = defineStore( }) const totalExpense = computed(() => - sortedTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0), + sortedTransactions.value.reduce((sum, tx) => (shouldCountAsExpense(tx) ? sum + tx.amount : sum), 0), ) const totalIncome = computed(() => - sortedTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0), + sortedTransactions.value.reduce((sum, tx) => (shouldCountAsIncome(tx) ? sum + tx.amount : sum), 0), ) const todaysExpense = computed(() => - todaysTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0), + todaysTransactions.value.reduce((sum, tx) => (shouldCountAsExpense(tx) ? sum + tx.amount : sum), 0), ) const todaysIncome = computed(() => - todaysTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0), + todaysTransactions.value.reduce((sum, tx) => (shouldCountAsIncome(tx) ? sum + tx.amount : sum), 0), ) const latestTransactions = computed(() => sortedTransactions.value.slice(0, 5)) @@ -90,7 +91,7 @@ export const useTransactionStore = defineStore( dayKey, label: formatDayLabel(dayKey), totalExpense: records - .filter((tx) => tx.amount < 0) + .filter((tx) => shouldCountAsExpense(tx)) .reduce((sum, tx) => sum + Math.abs(tx.amount), 0), items: records, })) @@ -117,10 +118,21 @@ export const useTransactionStore = defineStore( } } + const upsertTransaction = (nextTransaction) => { + transactions.value = replaceTransaction(transactions.value, nextTransaction) + } + const enrichTransactionInBackground = async (transaction) => { - const enriched = await maybeEnrichTransactionWithAi(transaction) + const enriched = await maybeEnrichTransactionWithAi(transaction, { + onProgress: (nextTransaction) => { + if (nextTransaction?.id === transaction.id) { + upsertTransaction(nextTransaction) + } + }, + }) + if (!enriched || enriched.id !== transaction.id) return - transactions.value = replaceTransaction(transactions.value, enriched) + upsertTransaction(enriched) } const addTransaction = async (payload) => { @@ -141,7 +153,7 @@ export const useTransactionStore = defineStore( } const updated = await updateTransaction(normalized) if (updated) { - transactions.value = replaceTransaction(transactions.value, updated) + upsertTransaction(updated) void enrichTransactionInBackground(updated) } return updated diff --git a/src/stores/ui.js b/src/stores/ui.js index c8ac49e..f8c524b 100644 --- a/src/stores/ui.js +++ b/src/stores/ui.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -// 负责全局 UI 状态,例如新增记录的底部弹窗 +// 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。 export const useUiStore = defineStore('ui', () => { const addEntryVisible = ref(false) const editingTransactionId = ref('') @@ -23,4 +23,3 @@ export const useUiStore = defineStore('ui', () => { closeAddEntry, } }) - diff --git a/src/types/transaction.d.ts b/src/types/transaction.d.ts index 231baf9..9d8b455 100644 --- a/src/types/transaction.d.ts +++ b/src/types/transaction.d.ts @@ -1,4 +1,4 @@ -export interface Transaction { +export interface Transaction { id: string; amount: number; merchant: string; @@ -6,11 +6,18 @@ export interface Transaction { date: string; note?: string; syncStatus: 'pending' | 'synced' | 'error'; + entryType?: 'expense' | 'income' | 'transfer'; + fundSourceType?: 'cash' | 'bank' | 'credit' | 'stored_value' | 'merchant' | 'income_source' | 'external' | 'unknown'; + fundSourceName?: string; + fundTargetType?: 'cash' | 'bank' | 'credit' | 'stored_value' | 'merchant' | 'income_source' | 'external' | 'unknown'; + fundTargetName?: string; + impactExpense?: boolean; + impactIncome?: boolean; aiCategory?: string; aiTags?: string[]; aiConfidence?: number; aiReason?: string; - aiStatus?: 'idle' | 'applied' | 'suggested' | 'failed'; + aiStatus?: 'idle' | 'running' | 'applied' | 'suggested' | 'failed'; aiModel?: string; aiNormalizedMerchant?: string; } diff --git a/src/views/AddEntryView.vue b/src/views/AddEntryView.vue index 1b4fda6..d39e896 100644 --- a/src/views/AddEntryView.vue +++ b/src/views/AddEntryView.vue @@ -217,7 +217,7 @@ const sheetStyle = computed(() => { input-class="text-2xl font-bold" > diff --git a/src/views/AnalysisView.vue b/src/views/AnalysisView.vue index e949df6..2e7ccf9 100644 --- a/src/views/AnalysisView.vue +++ b/src/views/AnalysisView.vue @@ -1,5 +1,102 @@