diff --git a/build.gradle b/build.gradle index 80f9c00..b88d4d5 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-mail' + // Thymeleaf(TEXT 模式):加载 classpath:/prompts 下的 AI 提示词模板 + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.security:spring-security-crypto' implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12' diff --git a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java new file mode 100644 index 0000000..fc3b289 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java @@ -0,0 +1,202 @@ +package cn.rosercode.finance.prompt; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import cn.rosercode.finance.common.BusinessException; + +/** + * AI 提示词模板渲染组件。 + * + *

统一从 {@code classpath:/prompts/} 加载 Thymeleaf 文本模板(TEXT 模式)。 + * 每个模板对应一个语义化方法,调用方只需传入该模板所需数据、无需关心模板名, + * 使提示词与 Java 代码解耦、便于集中维护。模板解析规则见 + * {@code application.yml} 的 {@code spring.thymeleaf} 配置。

+ * + * @date 2026-09-21 10:58:05 + */ +@Component +public class PromptTemplateService { + + /** Thymeleaf 模板引擎,模板前缀、后缀与模式由配置决定 */ + private final TemplateEngine templateEngine; + + /** + * 构造方法,注入模板引擎。 + * + * @param templateEngine Thymeleaf 模板引擎 + */ + public PromptTemplateService(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + /** + * 渲染自然语言记账的系统提示词(模板 {@code ai-record-system})。 + * + * @param typeDesc 收支类型说明 + * @param channelDesc 交易渠道说明 + * @param categoryNames 可用分类名称 + * @param tagNames 可用标签名称 + * @param now 当前时间(yyyy-MM-dd HH:mm:ss) + * @param exampleTime 示例交易时间(yyyy-MM-dd'T'HH:mm:ss) + * @return 系统提示词 + */ + public String recordSystemPrompt(String typeDesc, String channelDesc, String categoryNames, + String tagNames, String now, String exampleTime) { + Map variables = new LinkedHashMap<>(); + variables.put("typeDesc", typeDesc); + variables.put("channelDesc", channelDesc); + variables.put("categoryNames", categoryNames); + variables.put("tagNames", tagNames); + variables.put("now", now); + variables.put("exampleTime", exampleTime); + return render("ai-record-system", variables); + } + + /** + * 渲染自然语言查询的系统提示词(模板 {@code ai-query-system})。 + * + * @param typeDesc 收支类型说明 + * @param channelDesc 交易渠道说明 + * @param categoryNames 可用分类名称 + * @param tagNames 可用标签名称 + * @param today 当天日期(yyyy-MM-dd) + * @return 系统提示词 + */ + public String querySystemPrompt(String typeDesc, String channelDesc, String categoryNames, + String tagNames, String today) { + Map variables = new LinkedHashMap<>(); + variables.put("typeDesc", typeDesc); + variables.put("channelDesc", channelDesc); + variables.put("categoryNames", categoryNames); + variables.put("tagNames", tagNames); + variables.put("today", today); + return render("ai-query-system", variables); + } + + /** + * 渲染自然语言统计查询的系统提示词(模板 {@code ai-statistics-system})。 + * + * @param typeDesc 收支类型说明 + * @param channelDesc 交易渠道说明 + * @param categoryNames 可用分类名称 + * @param tagNames 可用标签名称 + * @param today 当天日期(yyyy-MM-dd) + * @return 系统提示词 + */ + public String statisticsSystemPrompt(String typeDesc, String channelDesc, String categoryNames, + String tagNames, String today) { + Map variables = new LinkedHashMap<>(); + variables.put("typeDesc", typeDesc); + variables.put("channelDesc", channelDesc); + variables.put("categoryNames", categoryNames); + variables.put("tagNames", tagNames); + variables.put("today", today); + return render("ai-statistics-system", variables); + } + + /** + * 渲染生成分类的系统提示词(模板 {@code ai-category-system})。 + * + * @param typeDesc 收支类型说明 + * @return 系统提示词 + */ + public String categorySystemPrompt(String typeDesc) { + return render("ai-category-system", Map.of("typeDesc", typeDesc)); + } + + /** + * 渲染生成标签的系统提示词(模板 {@code ai-tag-system})。 + * + * @return 系统提示词 + */ + public String tagSystemPrompt() { + return render("ai-tag-system", null); + } + + /** + * 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。 + * + * @return 系统提示词 + */ + public String decorationEmojiSystemPrompt() { + return render("ai-decoration-emoji-system", null); + } + + /** + * 渲染配色建议的系统提示词(模板 {@code ai-decoration-color-system})。 + * + * @return 系统提示词 + */ + public String decorationColorSystemPrompt() { + return render("ai-decoration-color-system", null); + } + + /** + * 渲染严格模式分类标签建议的系统提示词(模板 {@code ai-suggestion-strict-system})。 + * + * @return 系统提示词 + */ + public String suggestionStrictSystemPrompt() { + return render("ai-suggestion-strict-system", null); + } + + /** + * 渲染增强模式分类标签建议的系统提示词(模板 {@code ai-suggestion-new-system})。 + * + * @param scope 建议范围(CATEGORY / TAG / BOTH),决定追加的限定说明 + * @return 系统提示词 + */ + public String suggestionNewSystemPrompt(String scope) { + return render("ai-suggestion-new-system", Map.of("scope", scope)); + } + + /** + * 渲染规则生成的系统提示词(模板 {@code ai-rule-system})。 + * + * @return 系统提示词 + */ + public String ruleSystemPrompt() { + return render("ai-rule-system", null); + } + + /** + * 渲染连接测试的系统提示词(模板 {@code ai-config-test-system})。 + * + * @return 系统提示词 + */ + public String configTestSystemPrompt() { + return render("ai-config-test-system", null); + } + + /** + * 渲染连接测试的用户提示词(模板 {@code ai-config-test-user})。 + * + * @return 用户提示词 + */ + public String configTestUserPrompt() { + return render("ai-config-test-user", null); + } + + /** + * 渲染提示词模板,模板名与变量由各专用方法提供。 + * + * @param template 模板名(不含 {@code .tpl} 后缀) + * @param variables 模板变量,允许为 null + * @return 渲染后的提示词文本 + */ + private String render(String template, Map variables) { + Context context = new Context(Locale.CHINA, variables == null ? Collections.emptyMap() : variables); + try { + return templateEngine.process(template, context); + } catch (RuntimeException e) { + throw new BusinessException("加载提示词模板失败: " + template); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java index c8c47a1..06b6308 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java @@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.entity.AiConfig; import cn.rosercode.finance.enums.AiCallType; import cn.rosercode.finance.enums.TransactionType; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiCategoryService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiUsageService; @@ -35,26 +36,22 @@ public class AiCategoryServiceImpl implements AiCategoryService { /** 合法的收支类型,统一取自类型枚举 */ private static final List VALID_TYPES = TransactionType.codes(); - /** 系统提示词,要求模型严格返回 JSON */ - private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。" - + "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。" - + "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 " + TransactionType.describe() + ";" - + "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。" - + "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}"; - private final AiConfigService aiConfigService; private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiCategoryServiceImpl(AiConfigService aiConfigService, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -73,7 +70,8 @@ public class AiCategoryServiceImpl implements AiCategoryService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - SYSTEM_PROMPT, text.trim()); + promptTemplateService.categorySystemPrompt(TransactionType.describe()), + text.trim()); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java index 441e97c..1ec0af0 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java @@ -12,6 +12,7 @@ import cn.rosercode.finance.dto.AiConfigRequest; import cn.rosercode.finance.entity.AiConfig; import cn.rosercode.finance.enums.AiCallType; import cn.rosercode.finance.mapper.AiConfigMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiUsageService; import cn.rosercode.finance.vo.AiConfigVO; @@ -34,12 +35,14 @@ public class AiConfigServiceImpl implements AiConfigService { private final AiConfigMapper aiConfigMapper; private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; + private final PromptTemplateService promptTemplateService; public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient, - AiUsageService aiUsageService) { + AiUsageService aiUsageService, PromptTemplateService promptTemplateService) { this.aiConfigMapper = aiConfigMapper; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; + this.promptTemplateService = promptTemplateService; } /** @@ -119,8 +122,8 @@ public class AiConfigServiceImpl implements AiConfigService { DeepSeekResponse response; try { response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - "你是一个测试助手,请只输出 JSON。", - "请返回 {\"ok\":true}"); + promptTemplateService.configTestSystemPrompt(), + promptTemplateService.configTestUserPrompt()); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java index 23e75cf..ddc292f 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java @@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.entity.AiConfig; import cn.rosercode.finance.enums.AiCallType; import cn.rosercode.finance.enums.TransactionType; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiDecorationService; import cn.rosercode.finance.service.AiUsageService; @@ -25,16 +26,6 @@ import cn.rosercode.finance.service.AiUsageService; @Service public class AiDecorationServiceImpl implements AiDecorationService { - /** Emoji 建议提示词 */ - private static final String EMOJI_SYSTEM_PROMPT = "你是一个个人记账应用的图标助手。" - + "请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。" - + "只输出 JSON,不要输出任何多余内容。输出格式:{\"emoji\":\"🍜\"}"; - - /** 颜色建议提示词 */ - private static final String COLOR_SYSTEM_PROMPT = "你是一个个人记账应用的配色助手。" - + "请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。" - + "只输出 JSON,不要输出任何多余内容。输出格式:{\"color\":\"#18a058\"}"; - /** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */ private static final int EMOJI_MAX_LENGTH = 16; @@ -45,15 +36,18 @@ public class AiDecorationServiceImpl implements AiDecorationService { private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiDecorationServiceImpl(AiConfigService aiConfigService, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -66,7 +60,8 @@ public class AiDecorationServiceImpl implements AiDecorationService { String trimmed = requireName(name); AiConfig config = requireConfig(userId); String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type); - JsonNode root = chatAndParse(userId, config, EMOJI_SYSTEM_PROMPT, userPrompt); + JsonNode root = chatAndParse(userId, config, + promptTemplateService.decorationEmojiSystemPrompt(), userPrompt); String emoji = root.path("emoji").asText("").trim(); if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) { throw new BusinessException("AI 未返回有效的 Emoji,请重试"); @@ -84,7 +79,8 @@ public class AiDecorationServiceImpl implements AiDecorationService { String trimmed = requireName(name); AiConfig config = requireConfig(userId); String userPrompt = "标签名称:" + trimmed; - JsonNode root = chatAndParse(userId, config, COLOR_SYSTEM_PROMPT, userPrompt); + JsonNode root = chatAndParse(userId, config, + promptTemplateService.decorationColorSystemPrompt(), userPrompt); String color = root.path("color").asText("").trim(); if (!COLOR_PATTERN.matcher(color).matches()) { throw new BusinessException("AI 未返回有效的颜色,请重试"); diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java index 586ad19..2ec0c36 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java @@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType; import cn.rosercode.finance.enums.TransactionType; import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiQueryService; import cn.rosercode.finance.service.AiUsageService; @@ -56,19 +57,22 @@ public class AiQueryServiceImpl implements AiQueryService { private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiQueryServiceImpl(AiConfigService aiConfigService, CategoryMapper categoryMapper, TagMapper tagMapper, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.categoryMapper = categoryMapper; this.tagMapper = tagMapper; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -122,19 +126,12 @@ public class AiQueryServiceImpl implements AiQueryService { private String buildSystemPrompt(List categories, List tags) { String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、")); String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、")); - String today = LocalDate.now().format(DATE_FMT); - return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n" - + "可用的收支类型:" + TransactionType.describe() + "。\n" - + "可用的交易渠道:" + ChannelType.describe() + "。\n" - + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n" - + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n" - + "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、" - + "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n" - + "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n" - + "今天是 " + today + "。\n" - + "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[]," - + "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null," - + "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}"; + return promptTemplateService.querySystemPrompt( + TransactionType.describe(), + ChannelType.describe(), + categoryNames.isEmpty() ? "无" : categoryNames, + tagNames.isEmpty() ? "无" : tagNames, + LocalDate.now().format(DATE_FMT)); } /** diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java index 3bb57c5..a92e442 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java @@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType; import cn.rosercode.finance.enums.TransactionType; import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiRecordService; import cn.rosercode.finance.service.AiUsageService; @@ -53,19 +54,22 @@ public class AiRecordServiceImpl implements AiRecordService { private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiRecordServiceImpl(AiConfigService aiConfigService, CategoryMapper categoryMapper, TagMapper tagMapper, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.categoryMapper = categoryMapper; this.tagMapper = tagMapper; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -119,22 +123,14 @@ public class AiRecordServiceImpl implements AiRecordService { private String buildSystemPrompt(List categories, List tags) { String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、")); String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、")); - String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n" - + "可用的收支类型:" + TransactionType.describe() + "。\n" - + "可用的交易渠道:" + ChannelType.describe() + "。\n" - + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n" - + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n" - + "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;" - + "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;" - + "4. tagNames 从可用标签中选择(数组,可为空);5. channel 从可用交易渠道中选择,无法判断为 null;" - + "6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;" - + "7. includeInStats 默认 true;8. remark 给出简洁备注。\n" - + "当前时间:" + now + "。\n" - + "输出格式:{\"summary\":\"简要说明识别到的记账内容\",\"type\":\"EXPENSE\",\"amount\":35.5," - + "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"channel\":\"ALIPAY\"," - + "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\"," - + "\"includeInStats\":true,\"unmatched\":[]}"; + LocalDateTime current = LocalDateTime.now(); + return promptTemplateService.recordSystemPrompt( + TransactionType.describe(), + ChannelType.describe(), + categoryNames.isEmpty() ? "无" : categoryNames, + tagNames.isEmpty() ? "无" : tagNames, + current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + current.format(DATETIME_FMT)); } /** diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java index d1f58ea..c5859c0 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java @@ -38,6 +38,7 @@ import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TransactionMapper; import cn.rosercode.finance.mapper.TransactionTagMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiRuleService; import cn.rosercode.finance.service.AiUsageService; @@ -80,24 +81,6 @@ public class AiRuleServiceImpl implements AiRuleService { /** 合法的十六进制颜色 */ private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$"); - /** 系统提示词,要求模型严格返回 JSON */ - private static final String SYSTEM_PROMPT = "你是一个个人记账应用的规则生成助手。" - + "请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。\n" - + "每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。\n" - + "规则字段说明:\n" - + "- name:规则名称,简洁中文,不超过 20 字;\n" - + "- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;\n" - + "- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;\n" - + "- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;\n" - + "- newCategory:现有分类都不合适时给出 {\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🚌\"},否则为 null;\n" - + "- tagIds:从「可用标签」中选择,数组;\n" - + "- newTags:需要新建的标签,格式 [{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],可为空数组;\n" - + "- reason:简要中文理由。\n" - + "优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。\n" - + "输出格式:{\"summary\":\"简要中文摘要\",\"rules\":[{\"name\":\"...\",\"type\":\"EXPENSE\"," - + "\"keyword\":\"星巴克\",\"categoryId\":12,\"newCategory\":null,\"tagIds\":[3]," - + "\"newTags\":[],\"reason\":\"...\"}]}"; - private final AiConfigService aiConfigService; private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; @@ -107,6 +90,7 @@ public class AiRuleServiceImpl implements AiRuleService { private final TagMapper tagMapper; private final TransactionTagMapper transactionTagMapper; private final RuleService ruleService; + private final PromptTemplateService promptTemplateService; public AiRuleServiceImpl(AiConfigService aiConfigService, DeepSeekClient deepSeekClient, @@ -116,7 +100,8 @@ public class AiRuleServiceImpl implements AiRuleService { CategoryMapper categoryMapper, TagMapper tagMapper, TransactionTagMapper transactionTagMapper, - RuleService ruleService) { + RuleService ruleService, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; @@ -126,6 +111,7 @@ public class AiRuleServiceImpl implements AiRuleService { this.tagMapper = tagMapper; this.transactionTagMapper = transactionTagMapper; this.ruleService = ruleService; + this.promptTemplateService = promptTemplateService; } /** @@ -169,7 +155,7 @@ public class AiRuleServiceImpl implements AiRuleService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - SYSTEM_PROMPT, userPrompt); + promptTemplateService.ruleSystemPrompt(), userPrompt); } catch (RuntimeException e) { aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(), System.currentTimeMillis() - start, e.getMessage()); @@ -456,6 +442,9 @@ public class AiRuleServiceImpl implements AiRuleService { /** * 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。 * + *

隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、备注、交易渠道), + * 禁止加入 amount、rawData、externalNo 等敏感字段。

+ * * @param categories 用户分类列表 * @param tags 用户标签列表 * @param transactions 选中的记录列表 @@ -492,6 +481,7 @@ public class AiRuleServiceImpl implements AiRuleService { if (!seen.add(key)) { continue; } + // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息 Map node = new LinkedHashMap<>(); node.put("type", transaction.getType()); node.put("remark", transaction.getRemark()); diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java index 2126d25..0ab124c 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java @@ -28,6 +28,7 @@ import cn.rosercode.finance.enums.StatisticsScope; import cn.rosercode.finance.enums.TransactionType; import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiStatisticsService; import cn.rosercode.finance.service.AiUsageService; @@ -68,19 +69,22 @@ public class AiStatisticsServiceImpl implements AiStatisticsService { private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiStatisticsServiceImpl(AiConfigService aiConfigService, CategoryMapper categoryMapper, TagMapper tagMapper, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.categoryMapper = categoryMapper; this.tagMapper = tagMapper; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -134,21 +138,12 @@ public class AiStatisticsServiceImpl implements AiStatisticsService { private String buildSystemPrompt(List categories, List tags) { String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、")); String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、")); - String today = LocalDate.now().format(DATE_FMT); - return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n" - + "可用的收支类型:" + TransactionType.describe() + "。\n" - + "可用的交易渠道:" + ChannelType.describe() + "。\n" - + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n" - + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n" - + "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、" - + "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n" - + "dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。\n" - + "statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。\n" - + "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n" - + "今天是 " + today + "。\n" - + "输出格式:{\"summary\":\"简要说明识别到的统计意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[]," - + "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null," - + "\"maxAmount\":null,\"remark\":null,\"statsScope\":null,\"dimension\":null,\"unmatched\":[]}"; + return promptTemplateService.statisticsSystemPrompt( + TransactionType.describe(), + ChannelType.describe(), + categoryNames.isEmpty() ? "无" : categoryNames, + tagNames.isEmpty() ? "无" : tagNames, + LocalDate.now().format(DATE_FMT)); } /** diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java index 068aae0..29ec7a9 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java @@ -36,6 +36,7 @@ import cn.rosercode.finance.mapper.CategoryMapper; import cn.rosercode.finance.mapper.TagMapper; import cn.rosercode.finance.mapper.TransactionMapper; import cn.rosercode.finance.mapper.TransactionTagMapper; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiSuggestionService; import cn.rosercode.finance.service.AiUsageService; @@ -68,27 +69,6 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { /** 十六进制颜色校验 */ private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$"); - /** 严格模式系统提示词,仅允许使用现有分类与标签,供编辑弹窗单条建议使用 */ - private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息," - + "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。" - + "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);" - + "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;" - + "4. 只输出 JSON,不要输出任何多余内容。" - + "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}"; - - /** 增强模式系统提示词,允许在现有候选都不合适时提出新建分类与标签 */ - private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息," - + "为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。" - + "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);" - + "2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;" - + "3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji)," - + "或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);" - + "4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;" - + "5. 标签可多选,也可为空数组;6. 只输出 JSON,不要输出任何多余内容。" - + "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID或null," - + "\"newCategory\":{\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🐾\"}或null," - + "\"tagIds\":[标签ID],\"newTags\":[{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],\"reason\":\"简要中文理由\"}]}"; - private final AiConfigService aiConfigService; private final TransactionMapper transactionMapper; private final CategoryMapper categoryMapper; @@ -97,6 +77,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { private final DeepSeekClient deepSeekClient; private final ObjectMapper objectMapper; private final AiUsageService aiUsageService; + private final PromptTemplateService promptTemplateService; public AiSuggestionServiceImpl(AiConfigService aiConfigService, TransactionMapper transactionMapper, @@ -105,7 +86,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { TransactionTagMapper transactionTagMapper, DeepSeekClient deepSeekClient, ObjectMapper objectMapper, - AiUsageService aiUsageService) { + AiUsageService aiUsageService, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.transactionMapper = transactionMapper; this.categoryMapper = categoryMapper; @@ -114,6 +96,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { this.deepSeekClient = deepSeekClient; this.objectMapper = objectMapper; this.aiUsageService = aiUsageService; + this.promptTemplateService = promptTemplateService; } /** @@ -242,7 +225,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - STRICT_SYSTEM_PROMPT, userPrompt); + promptTemplateService.suggestionStrictSystemPrompt(), userPrompt); } catch (RuntimeException e) { aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1, System.currentTimeMillis() - start, e.getMessage()); @@ -348,13 +331,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { * @return 系统提示词 */ private String buildSystemPrompt(String scope) { - if ("CATEGORY".equals(scope)) { - return NEW_SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。"; - } - if ("TAG".equals(scope)) { - return NEW_SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。"; - } - return NEW_SYSTEM_PROMPT; + return promptTemplateService.suggestionNewSystemPrompt(scope); } /** @@ -573,6 +550,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { /** * 构建用户提示词,将分类、标签与交易记录序列化为 JSON。 * + *

隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、交易时间、渠道、备注), + * 禁止加入 amount、rawData、externalNo 等敏感字段。

+ * * @param categories 用户分类列表 * @param tags 用户标签列表 * @param transactions 待分析记录列表 @@ -593,6 +573,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { node.put("name", tag.getName()); return node; }).collect(Collectors.toList())); + // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息 payload.put("transactions", transactions.stream().map(transaction -> { Map node = new LinkedHashMap<>(); node.put("id", transaction.getId()); diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java index bf15e79..9001c26 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java @@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse; import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.entity.AiConfig; import cn.rosercode.finance.enums.AiCallType; +import cn.rosercode.finance.prompt.PromptTemplateService; import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiTagService; import cn.rosercode.finance.service.AiUsageService; @@ -34,27 +35,22 @@ public class AiTagServiceImpl implements AiTagService { /** 十六进制颜色校验 */ private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$"); - /** 系统提示词,要求模型严格返回 JSON */ - private static final String SYSTEM_PROMPT = "你是一个个人记账应用的标签助手。" - + "请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。" - + "要求:1. name 为 2-6 个汉字的标签名称;" - + "2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;" - + "3. 只输出 JSON,不要输出任何多余内容。" - + "输出格式:{\"name\":\"报销\",\"color\":\"#2f80ed\",\"reason\":\"简要中文理由\"}"; - private final AiConfigService aiConfigService; private final DeepSeekClient deepSeekClient; private final AiUsageService aiUsageService; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public AiTagServiceImpl(AiConfigService aiConfigService, DeepSeekClient deepSeekClient, AiUsageService aiUsageService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.aiConfigService = aiConfigService; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } /** @@ -73,7 +69,7 @@ public class AiTagServiceImpl implements AiTagService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - SYSTEM_PROMPT, text.trim()); + promptTemplateService.tagSystemPrompt(), text.trim()); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index af3c68b..e118f83 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,13 @@ spring: multipart: max-file-size: 10MB max-request-size: 10MB + # AI 提示词模板:TEXT 模式加载 classpath:/prompts/*.tpl, + # 由 cn.rosercode.finance.prompt.PromptTemplateService 统一渲染 + thymeleaf: + prefix: classpath:/prompts/ + suffix: .tpl + mode: TEXT + encoding: UTF-8 # 数据源不再在此固定配置,改由运行时数据库配置(config/database.json)决定, # 详见 cn.rosercode.finance.config.datasource 包。 diff --git a/src/main/resources/prompts/ai-category-system.tpl b/src/main/resources/prompts/ai-category-system.tpl new file mode 100644 index 0000000..8548346 --- /dev/null +++ b/src/main/resources/prompts/ai-category-system.tpl @@ -0,0 +1,5 @@ +你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求: +1. name 为 2-6 个汉字的分类名称; +2. type 只能是 [(${typeDesc})]; +3. icon 为与该分类最贴切的一个 Emoji; +4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-config-test-system.tpl b/src/main/resources/prompts/ai-config-test-system.tpl new file mode 100644 index 0000000..2e5156b --- /dev/null +++ b/src/main/resources/prompts/ai-config-test-system.tpl @@ -0,0 +1 @@ +你是一个测试助手,请只输出 JSON。 \ No newline at end of file diff --git a/src/main/resources/prompts/ai-config-test-user.tpl b/src/main/resources/prompts/ai-config-test-user.tpl new file mode 100644 index 0000000..cbeac32 --- /dev/null +++ b/src/main/resources/prompts/ai-config-test-user.tpl @@ -0,0 +1 @@ +请返回 {"ok":true} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-decoration-color-system.tpl b/src/main/resources/prompts/ai-decoration-color-system.tpl new file mode 100644 index 0000000..9a6d390 --- /dev/null +++ b/src/main/resources/prompts/ai-decoration-color-system.tpl @@ -0,0 +1,2 @@ +你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。 +只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-decoration-emoji-system.tpl b/src/main/resources/prompts/ai-decoration-emoji-system.tpl new file mode 100644 index 0000000..fc8a726 --- /dev/null +++ b/src/main/resources/prompts/ai-decoration-emoji-system.tpl @@ -0,0 +1,2 @@ +你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。 +只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-query-system.tpl b/src/main/resources/prompts/ai-query-system.tpl new file mode 100644 index 0000000..259eae8 --- /dev/null +++ b/src/main/resources/prompts/ai-query-system.tpl @@ -0,0 +1,11 @@ +你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。 +可用的收支类型:[(${typeDesc})]。 +可用的交易渠道:[(${channelDesc})]。 +可用分类:[(${categoryNames})]。 +可用标签:[(${tagNames})]。 +时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE; +若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。 +未提及的字段请留空(数组用 [],其余用 null)。 +分类和标签只能使用上面给出的名称。 +今天是 [(${today})]。 +输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-record-system.tpl b/src/main/resources/prompts/ai-record-system.tpl new file mode 100644 index 0000000..e93fe24 --- /dev/null +++ b/src/main/resources/prompts/ai-record-system.tpl @@ -0,0 +1,16 @@ +你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。 +可用的收支类型:[(${typeDesc})]。 +可用的交易渠道:[(${channelDesc})]。 +可用分类:[(${categoryNames})]。 +可用标签:[(${tagNames})]。 +规则: +1. type 默认为 EXPENSE,除非明显是收入; +2. amount 为必填数字; +3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组; +4. tagNames 从可用标签中选择(数组,可为空); +5. channel 从可用交易渠道中选择,无法判断为 null; +6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算; +7. includeInStats 默认 true; +8. remark 给出简洁备注。 +当前时间:[(${now})]。 +输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-rule-system.tpl b/src/main/resources/prompts/ai-rule-system.tpl new file mode 100644 index 0000000..94410aa --- /dev/null +++ b/src/main/resources/prompts/ai-rule-system.tpl @@ -0,0 +1,13 @@ +你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。 +每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。 +规则字段说明: +- name:规则名称,简洁中文,不超过 20 字; +- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null; +- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null; +- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null; +- newCategory:现有分类都不合适时给出 {"name":"分类名","type":"EXPENSE","icon":"🚌"},否则为 null; +- tagIds:从「可用标签」中选择,数组; +- newTags:需要新建的标签,格式 [{"name":"标签名","color":"#2f80ed"}],可为空数组; +- reason:简要中文理由。 +优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。 +输出格式:{"summary":"简要中文摘要","rules":[{"name":"...","type":"EXPENSE","keyword":"星巴克","categoryId":12,"newCategory":null,"tagIds":[3],"newTags":[],"reason":"..."}]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-statistics-system.tpl b/src/main/resources/prompts/ai-statistics-system.tpl new file mode 100644 index 0000000..9d7c049 --- /dev/null +++ b/src/main/resources/prompts/ai-statistics-system.tpl @@ -0,0 +1,13 @@ +你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。 +可用的收支类型:[(${typeDesc})]。 +可用的交易渠道:[(${channelDesc})]。 +可用分类:[(${categoryNames})]。 +可用标签:[(${tagNames})]。 +时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE; +若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。 +dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。 +statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。 +未提及的字段请留空(数组用 [],其余用 null)。 +分类和标签只能使用上面给出的名称。 +今天是 [(${today})]。 +输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-suggestion-new-system.tpl b/src/main/resources/prompts/ai-suggestion-new-system.tpl new file mode 100644 index 0000000..917fe90 --- /dev/null +++ b/src/main/resources/prompts/ai-suggestion-new-system.tpl @@ -0,0 +1,9 @@ +你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。要求: +1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类); +2. 优先使用给定列表中的 ID,不要编造列表之外的 ID; +3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色); +4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组; +5. 标签可多选,也可为空数组; +6. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID或null,"newCategory":{"name":"分类名","type":"EXPENSE","icon":"🐾"}或null,"tagIds":[标签ID],"newTags":[{"name":"标签名","color":"#2f80ed"}],"reason":"简要中文理由"}]} +[# th:if="${scope == 'CATEGORY'}"]注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。[/] +[# th:if="${scope == 'TAG'}"]注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。[/] \ No newline at end of file diff --git a/src/main/resources/prompts/ai-suggestion-strict-system.tpl b/src/main/resources/prompts/ai-suggestion-strict-system.tpl new file mode 100644 index 0000000..73f7c7e --- /dev/null +++ b/src/main/resources/prompts/ai-suggestion-strict-system.tpl @@ -0,0 +1,5 @@ +你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。要求: +1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类); +2. 只能使用给定列表中的 ID,不要编造; +3. 标签可多选,也可为空数组; +4. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"reason":"简要中文理由"}]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-tag-system.tpl b/src/main/resources/prompts/ai-tag-system.tpl new file mode 100644 index 0000000..c278203 --- /dev/null +++ b/src/main/resources/prompts/ai-tag-system.tpl @@ -0,0 +1,4 @@ +你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求: +1. name 为 2-6 个汉字的标签名称; +2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色; +3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"} \ No newline at end of file diff --git a/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java b/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java new file mode 100644 index 0000000..3b8601f --- /dev/null +++ b/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java @@ -0,0 +1,65 @@ +package cn.rosercode.finance.prompt; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +/** + * 提示词模板渲染测试,验证各模板可正常加载、变量替换与 scope 条件分支。 + * + * @date 2026-09-21 11:01:25 + */ +class PromptTemplateServiceTest { + + private PromptTemplateService promptTemplateService; + + @BeforeEach + void setUp() { + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix("prompts/"); + resolver.setSuffix(".tpl"); + resolver.setTemplateMode(TemplateMode.TEXT); + resolver.setCharacterEncoding("UTF-8"); + SpringTemplateEngine engine = new SpringTemplateEngine(); + engine.setTemplateResolver(resolver); + promptTemplateService = new PromptTemplateService(engine); + } + + @Test + void renderRecordSystemPrompt() { + String result = promptTemplateService.recordSystemPrompt( + "INCOME(收入)、EXPENSE(支出)", + "ALIPAY(支付宝)", + "餐饮", + "无", + "2026-09-21 11:00:00", + "2026-09-21T11:00:00"); + assertThat(result).contains("可用的收支类型:INCOME(收入)、EXPENSE(支出)。"); + assertThat(result).contains("可用分类:餐饮。"); + assertThat(result).contains("可用标签:无。"); + assertThat(result).contains("当前时间:2026-09-21 11:00:00。"); + assertThat(result).contains("\"transactionTime\":\"2026-09-21T11:00:00\""); + } + + @Test + void renderSuggestionScopeSuffix() { + String both = promptTemplateService.suggestionNewSystemPrompt("BOTH"); + assertThat(both).doesNotContain("注意:本次只需给出"); + String category = promptTemplateService.suggestionNewSystemPrompt("CATEGORY"); + assertThat(category).contains("注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。"); + String tag = promptTemplateService.suggestionNewSystemPrompt("TAG"); + assertThat(tag).contains("注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。"); + } + + @Test + void renderStaticPrompts() { + assertThat(promptTemplateService.tagSystemPrompt()).contains("你是一个个人记账应用的标签助手。"); + assertThat(promptTemplateService.ruleSystemPrompt()).contains("你是一个个人记账应用的规则生成助手。"); + assertThat(promptTemplateService.configTestSystemPrompt()).contains("你是一个测试助手,请只输出 JSON。"); + assertThat(promptTemplateService.configTestUserPrompt()).contains("请返回 {\"ok\":true}"); + } +} diff --git a/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java b/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java new file mode 100644 index 0000000..3c4ecba --- /dev/null +++ b/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java @@ -0,0 +1,124 @@ +package cn.rosercode.finance.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import cn.rosercode.finance.entity.Category; +import cn.rosercode.finance.entity.Tag; +import cn.rosercode.finance.entity.Transaction; + +/** + * AI 用户提示词隐私测试。 + * + *

校验 {@code buildUserPrompt} 生成的 JSON 载荷仅含白名单字段, + * 不含 amount、rawData、externalNo 等敏感信息,防止后续改动误加字段导致数据外发。

+ * + * @date 2026-09-21 11:10:58 + */ +class AiPromptPayloadPrivacyTest { + + /** 禁止出现在提示词载荷中的敏感字段名 */ + private static final List SENSITIVE_FIELDS = List.of("amount", "rawData", "externalNo"); + + /** + * AI 建议的批量用户提示词不得包含敏感字段。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + void suggestionUserPromptExcludesSensitiveFields() throws Exception { + AiSuggestionServiceImpl service = new AiSuggestionServiceImpl( + null, null, null, null, null, null, new ObjectMapper().findAndRegisterModules(), null, null); + Method method = AiSuggestionServiceImpl.class.getDeclaredMethod( + "buildUserPrompt", List.class, List.class, List.class); + method.setAccessible(true); + + String json = (String) method.invoke(service, + List.of(category()), List.of(tag()), List.of(transaction())); + + assertExcludesSensitiveFields(json); + assertThat(json).contains("remark").contains("午餐"); + } + + /** + * AI 规则生成的用户提示词不得包含敏感字段。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + void ruleUserPromptExcludesSensitiveFields() throws Exception { + AiRuleServiceImpl service = new AiRuleServiceImpl( + null, null, null, new ObjectMapper().findAndRegisterModules(), null, null, null, null, null, null); + Method method = AiRuleServiceImpl.class.getDeclaredMethod( + "buildUserPrompt", List.class, List.class, List.class, Map.class); + method.setAccessible(true); + + String json = (String) method.invoke(service, List.of(category()), List.of(tag()), + List.of(transaction()), Map.of(1L, List.of("报销"))); + + assertExcludesSensitiveFields(json); + assertThat(json).contains("remark").contains("午餐"); + } + + /** + * 断言载荷不含任何敏感字段名。 + * + * @param json 提示词载荷 + */ + private void assertExcludesSensitiveFields(String json) { + for (String field : SENSITIVE_FIELDS) { + assertThat(json).doesNotContain(field); + } + } + + /** + * 构造用于测试的分类。 + * + * @return 分类实体 + */ + private Category category() { + Category category = new Category(); + category.setId(1L); + category.setName("餐饮"); + category.setType("EXPENSE"); + return category; + } + + /** + * 构造用于测试的标签。 + * + * @return 标签实体 + */ + private Tag tag() { + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("报销"); + return tag; + } + + /** + * 构造用于测试的交易记录,并刻意写入敏感字段以验证其不会外发。 + * + * @return 交易记录实体 + */ + private Transaction transaction() { + Transaction transaction = new Transaction(); + transaction.setId(1L); + transaction.setType("EXPENSE"); + transaction.setChannel("ALIPAY"); + transaction.setRemark("午餐"); + transaction.setTransactionTime(LocalDateTime.now()); + transaction.setAmount(new BigDecimal("35.50")); + transaction.setExternalNo("2026092100001"); + transaction.setRawData("{\"secret\":\"raw\"}"); + return transaction; + } +}