From 338c72f7a53805e5c8fbd060acafe87bf90f102c Mon Sep 17 00:00:00 2001 From: rosercode Date: Wed, 30 Sep 2026 19:00:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20AI=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8A=BD=E5=8F=96=E4=B8=BA=20Thymeleaf=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将各 AI 服务中散落构造的提示词集中到 resources/prompts 的 Thymeleaf 文本模板,并把同一功能的系统提示词与用户提示词合并到同一文件的不同片段,由 __fragment 变量经 th:if 选择渲染,使提示词与 Java 代码进一步解耦、便于集中维护。 变更内容: - 新增用户提示词模板,覆盖记账、图片记账、查询、统计、转 SQL、生成分类/标签/渠道、账单关系、Emoji 与配色建议、分类标签建议、规则生成及连接测试等全部 AI 调用。 - 将系统提示词与用户提示词合并为 14 个 ai-*.tpl,每个文件含 system/user 片段(建议功能含 strictSystem/newSystem/user 三段),经 git mv 重命名、git rm 删除旧模板。 - PromptTemplateService 新增 13 个 xxxUserPrompt 方法,render 改为整模板渲染并注入 __fragment 选择片段。 - 各 AI 服务改为调用模板渲染用户提示词,白名单字段筛选与去重逻辑仍保留在 Java 侧。 说明: - 用户提示词载荷仅发送白名单字段,不含 amount、rawData、externalNo 等敏感信息。 - JSON 载荷中的字符串值在模板内用 #strings.escapeJavaScript 转义,保证输出为合法 JSON。 - 相较改造前,JSON 中值为 null 的字符串字段渲染为空串,category 为 null 时仍输出 null,语义等价。 --- .../prompt/PromptTemplateService.java | 237 +++++++++++++++--- .../service/impl/AiCategoryServiceImpl.java | 2 +- .../service/impl/AiChannelServiceImpl.java | 2 +- .../service/impl/AiDecorationServiceImpl.java | 4 +- .../impl/AiImageRecordServiceImpl.java | 2 +- .../service/impl/AiQueryServiceImpl.java | 2 +- .../service/impl/AiRecordServiceImpl.java | 2 +- .../service/impl/AiRelationServiceImpl.java | 28 ++- .../service/impl/AiRuleServiceImpl.java | 22 +- .../service/impl/AiSqlQueryServiceImpl.java | 2 +- .../service/impl/AiStatisticsServiceImpl.java | 2 +- .../service/impl/AiSuggestionServiceImpl.java | 31 +-- .../service/impl/AiTagServiceImpl.java | 2 +- ...ai-category-system.tpl => ai-category.tpl} | 8 +- .../{ai-channel-system.tpl => ai-channel.tpl} | 6 + .../prompts/ai-config-test-system.tpl | 1 - .../resources/prompts/ai-config-test-user.tpl | 1 - src/main/resources/prompts/ai-config-test.tpl | 7 + ...lor-system.tpl => ai-decoration-color.tpl} | 8 +- ...oji-system.tpl => ai-decoration-emoji.tpl} | 8 +- .../{ai-query-system.tpl => ai-query.tpl} | 8 +- ...d-image-system.tpl => ai-record-image.tpl} | 6 + .../{ai-record-system.tpl => ai-record.tpl} | 8 +- ...ai-relation-system.tpl => ai-relation.tpl} | 9 + .../{ai-rule-system.tpl => ai-rule.tpl} | 8 +- .../prompts/{ai-sql-system.tpl => ai-sql.tpl} | 6 + ...tatistics-system.tpl => ai-statistics.tpl} | 8 +- .../prompts/ai-suggestion-new-system.tpl | 9 - .../prompts/ai-suggestion-strict-system.tpl | 6 - src/main/resources/prompts/ai-suggestion.tpl | 24 ++ .../prompts/{ai-tag-system.tpl => ai-tag.tpl} | 8 +- 31 files changed, 364 insertions(+), 113 deletions(-) rename src/main/resources/prompts/{ai-category-system.tpl => ai-category.tpl} (78%) rename src/main/resources/prompts/{ai-channel-system.tpl => ai-channel.tpl} (81%) delete mode 100644 src/main/resources/prompts/ai-config-test-system.tpl delete mode 100644 src/main/resources/prompts/ai-config-test-user.tpl create mode 100644 src/main/resources/prompts/ai-config-test.tpl rename src/main/resources/prompts/{ai-decoration-color-system.tpl => ai-decoration-color.tpl} (65%) rename src/main/resources/prompts/{ai-decoration-emoji-system.tpl => ai-decoration-emoji.tpl} (59%) rename src/main/resources/prompts/{ai-query-system.tpl => ai-query.tpl} (88%) rename src/main/resources/prompts/{ai-record-image-system.tpl => ai-record-image.tpl} (91%) rename src/main/resources/prompts/{ai-record-system.tpl => ai-record.tpl} (87%) rename src/main/resources/prompts/{ai-relation-system.tpl => ai-relation.tpl} (80%) rename src/main/resources/prompts/{ai-rule-system.tpl => ai-rule.tpl} (56%) rename src/main/resources/prompts/{ai-sql-system.tpl => ai-sql.tpl} (95%) rename src/main/resources/prompts/{ai-statistics-system.tpl => ai-statistics.tpl} (90%) delete mode 100644 src/main/resources/prompts/ai-suggestion-new-system.tpl delete mode 100644 src/main/resources/prompts/ai-suggestion-strict-system.tpl create mode 100644 src/main/resources/prompts/ai-suggestion.tpl rename src/main/resources/prompts/{ai-tag-system.tpl => ai-tag.tpl} (80%) diff --git a/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java index d813c7e..0f1399a 100644 --- a/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java +++ b/src/main/java/cn/rosercode/ledgerly/prompt/PromptTemplateService.java @@ -1,7 +1,7 @@ package cn.rosercode.ledgerly.prompt; -import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -15,8 +15,9 @@ import cn.rosercode.ledgerly.common.BusinessException; * AI 提示词模板渲染组件。 * *

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

* * @date 2026-09-21 10:58:05 @@ -37,7 +38,7 @@ public class PromptTemplateService { } /** - * 渲染自然语言记账的系统提示词(模板 {@code ai-record-system})。 + * 渲染自然语言记账的系统提示词(模板 {@code ai-record} 的 {@code system} 片段)。 * * @param typeDesc 收支类型说明 * @param channelDesc 交易渠道说明 @@ -56,11 +57,11 @@ public class PromptTemplateService { variables.put("tagNames", tagNames); variables.put("now", now); variables.put("exampleTime", exampleTime); - return render("ai-record-system", variables); + return render("ai-record", "system", variables); } /** - * 渲染图片记账的系统提示词(模板 {@code ai-record-image-system})。 + * 渲染图片记账的系统提示词(模板 {@code ai-record-image} 的 {@code system} 片段)。 * * @param typeDesc 收支类型说明 * @param channelDesc 交易渠道说明 @@ -79,11 +80,11 @@ public class PromptTemplateService { variables.put("tagNames", tagNames); variables.put("now", now); variables.put("exampleTime", exampleTime); - return render("ai-record-image-system", variables); + return render("ai-record-image", "system", variables); } /** - * 渲染自然语言查询的系统提示词(模板 {@code ai-query-system})。 + * 渲染自然语言查询的系统提示词(模板 {@code ai-query} 的 {@code system} 片段)。 * * @param typeDesc 收支类型说明 * @param channelDesc 交易渠道说明 @@ -100,11 +101,11 @@ public class PromptTemplateService { variables.put("categoryNames", categoryNames); variables.put("tagNames", tagNames); variables.put("today", today); - return render("ai-query-system", variables); + return render("ai-query", "system", variables); } /** - * 渲染自然语言统计查询的系统提示词(模板 {@code ai-statistics-system})。 + * 渲染自然语言统计查询的系统提示词(模板 {@code ai-statistics} 的 {@code system} 片段)。 * * @param typeDesc 收支类型说明 * @param channelDesc 交易渠道说明 @@ -121,11 +122,11 @@ public class PromptTemplateService { variables.put("categoryNames", categoryNames); variables.put("tagNames", tagNames); variables.put("today", today); - return render("ai-statistics-system", variables); + return render("ai-statistics", "system", variables); } /** - * 渲染自然语言转 SQL 的系统提示词(模板 {@code ai-sql-system})。 + * 渲染自然语言转 SQL 的系统提示词(模板 {@code ai-sql} 的 {@code system} 片段)。 * * @param dialect 当前数据库方言(MySQL / SQLite) * @param today 当天日期(yyyy-MM-dd) @@ -137,119 +138,279 @@ public class PromptTemplateService { variables.put("dialect", dialect); variables.put("today", today); variables.put("schema", schema); - return render("ai-sql-system", variables); + return render("ai-sql", "system", variables); } /** - * 渲染生成分类的系统提示词(模板 {@code ai-category-system})。 + * 渲染生成分类的系统提示词(模板 {@code ai-category} 的 {@code system} 片段)。 * * @param typeDesc 收支类型说明 * @return 系统提示词 */ public String categorySystemPrompt(String typeDesc) { - return render("ai-category-system", Map.of("typeDesc", typeDesc)); + return render("ai-category", "system", Map.of("typeDesc", typeDesc)); } /** - * 渲染生成标签的系统提示词(模板 {@code ai-tag-system})。 + * 渲染生成标签的系统提示词(模板 {@code ai-tag} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String tagSystemPrompt() { - return render("ai-tag-system", null); + return render("ai-tag", "system", null); } /** - * 渲染生成交易渠道的系统提示词(模板 {@code ai-channel-system})。 + * 渲染生成交易渠道的系统提示词(模板 {@code ai-channel} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String channelSystemPrompt() { - return render("ai-channel-system", null); + return render("ai-channel", "system", null); } /** - * 渲染生成账单关系名称与备注的系统提示词(模板 {@code ai-relation-system})。 + * 渲染生成账单关系名称与备注的系统提示词(模板 {@code ai-relation} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String relationSystemPrompt() { - return render("ai-relation-system", null); + return render("ai-relation", "system", null); } /** - * 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。 + * 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String decorationEmojiSystemPrompt() { - return render("ai-decoration-emoji-system", null); + return render("ai-decoration-emoji", "system", null); } /** - * 渲染配色建议的系统提示词(模板 {@code ai-decoration-color-system})。 + * 渲染配色建议的系统提示词(模板 {@code ai-decoration-color} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String decorationColorSystemPrompt() { - return render("ai-decoration-color-system", null); + return render("ai-decoration-color", "system", null); } /** - * 渲染严格模式分类标签建议的系统提示词(模板 {@code ai-suggestion-strict-system})。 + * 渲染严格模式分类标签建议的系统提示词(模板 {@code ai-suggestion} 的 {@code strictSystem} 片段)。 * * @return 系统提示词 */ public String suggestionStrictSystemPrompt() { - return render("ai-suggestion-strict-system", null); + return render("ai-suggestion", "strictSystem", null); } /** - * 渲染增强模式分类标签建议的系统提示词(模板 {@code ai-suggestion-new-system})。 + * 渲染增强模式分类标签建议的系统提示词(模板 {@code ai-suggestion} 的 {@code newSystem} 片段)。 * * @param scope 建议范围(CATEGORY / TAG / BOTH),决定追加的限定说明 * @return 系统提示词 */ public String suggestionNewSystemPrompt(String scope) { - return render("ai-suggestion-new-system", Map.of("scope", scope)); + return render("ai-suggestion", "newSystem", Map.of("scope", scope)); } /** - * 渲染规则生成的系统提示词(模板 {@code ai-rule-system})。 + * 渲染规则生成的系统提示词(模板 {@code ai-rule} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String ruleSystemPrompt() { - return render("ai-rule-system", null); + return render("ai-rule", "system", null); } /** - * 渲染连接测试的系统提示词(模板 {@code ai-config-test-system})。 + * 渲染连接测试的系统提示词(模板 {@code ai-config-test} 的 {@code system} 片段)。 * * @return 系统提示词 */ public String configTestSystemPrompt() { - return render("ai-config-test-system", null); + return render("ai-config-test", "system", null); } /** - * 渲染连接测试的用户提示词(模板 {@code ai-config-test-user})。 + * 渲染连接测试的用户提示词(模板 {@code ai-config-test} 的 {@code user} 片段)。 * * @return 用户提示词 */ public String configTestUserPrompt() { - return render("ai-config-test-user", null); + return render("ai-config-test", "user", null); } /** - * 渲染提示词模板,模板名与变量由各专用方法提供。 + * 渲染自然语言记账的用户提示词(模板 {@code ai-record} 的 {@code user} 片段)。 + * + * @param text 用户输入的记账内容 + * @return 用户提示词 + */ + public String recordUserPrompt(String text) { + return render("ai-record", "user", Map.of("text", text)); + } + + /** + * 渲染图片记账的用户提示词(模板 {@code ai-record-image} 的 {@code user} 片段)。 + * + * @param hint 用户附加说明,可为 null(模板回退默认提示语) + * @return 用户提示词 + */ + public String recordImageUserPrompt(String hint) { + Map variables = new LinkedHashMap<>(); + variables.put("hint", hint); + return render("ai-record-image", "user", variables); + } + + /** + * 渲染自然语言查询的用户提示词(模板 {@code ai-query} 的 {@code user} 片段)。 + * + * @param query 用户输入的查询内容 + * @return 用户提示词 + */ + public String queryUserPrompt(String query) { + return render("ai-query", "user", Map.of("query", query)); + } + + /** + * 渲染自然语言统计查询的用户提示词(模板 {@code ai-statistics} 的 {@code user} 片段)。 + * + * @param query 用户输入的统计查询内容 + * @return 用户提示词 + */ + public String statisticsUserPrompt(String query) { + return render("ai-statistics", "user", Map.of("query", query)); + } + + /** + * 渲染自然语言转 SQL 的用户提示词(模板 {@code ai-sql} 的 {@code user} 片段)。 + * + * @param query 用户输入的查询内容 + * @return 用户提示词 + */ + public String textToSqlUserPrompt(String query) { + return render("ai-sql", "user", Map.of("query", query)); + } + + /** + * 渲染生成分类的用户提示词(模板 {@code ai-category} 的 {@code user} 片段)。 + * + * @param text 用户输入的分类描述 + * @return 用户提示词 + */ + public String categoryUserPrompt(String text) { + return render("ai-category", "user", Map.of("text", text)); + } + + /** + * 渲染生成标签的用户提示词(模板 {@code ai-tag} 的 {@code user} 片段)。 + * + * @param text 用户输入的标签描述 + * @return 用户提示词 + */ + public String tagUserPrompt(String text) { + return render("ai-tag", "user", Map.of("text", text)); + } + + /** + * 渲染生成交易渠道的用户提示词(模板 {@code ai-channel} 的 {@code user} 片段)。 + * + * @param text 用户输入的渠道描述 + * @return 用户提示词 + */ + public String channelUserPrompt(String text) { + return render("ai-channel", "user", Map.of("text", text)); + } + + /** + * 渲染 Emoji 建议的用户提示词(模板 {@code ai-decoration-emoji} 的 {@code user} 片段)。 + * + * @param name 分类名称 + * @param typeLabel 收支类型中文说明 + * @return 用户提示词 + */ + public String decorationEmojiUserPrompt(String name, String typeLabel) { + Map variables = new LinkedHashMap<>(); + variables.put("name", name); + variables.put("typeLabel", typeLabel); + return render("ai-decoration-emoji", "user", variables); + } + + /** + * 渲染配色建议的用户提示词(模板 {@code ai-decoration-color} 的 {@code user} 片段)。 + * + * @param name 标签名称 + * @return 用户提示词 + */ + public String decorationColorUserPrompt(String name) { + return render("ai-decoration-color", "user", Map.of("name", name)); + } + + /** + * 渲染账单关系的用户提示词(模板 {@code ai-relation} 的 {@code user} 片段)。 + * + * @param typeLabel 关系类型中文说明 + * @param members 成员账单列表(键:type、channel、categoryName、remark) + * @return 用户提示词 + */ + public String relationUserPrompt(String typeLabel, List> members) { + Map variables = new LinkedHashMap<>(); + variables.put("typeLabel", typeLabel); + variables.put("members", members); + return render("ai-relation", "user", variables); + } + + /** + * 渲染分类标签建议的用户提示词(模板 {@code ai-suggestion} 的 {@code user} 片段)。 + * + * @param categories 分类候选(键:id、name、type) + * @param tags 标签候选(键:id、name) + * @param channels 渠道候选(键:code、name),可为 null + * @param transactions 待分析记录(键:id、type、time、channel、remark) + * @return JSON 格式的用户提示词 + */ + public String suggestionUserPrompt(List> categories, List> tags, + List> channels, List> transactions) { + Map variables = new LinkedHashMap<>(); + variables.put("categories", categories); + variables.put("tags", tags); + variables.put("channels", channels); + variables.put("transactions", transactions); + return render("ai-suggestion", "user", variables); + } + + /** + * 渲染规则生成的用户提示词(模板 {@code ai-rule} 的 {@code user} 片段)。 + * + * @param categories 分类候选(键:id、name、type) + * @param tags 标签候选(键:id、name) + * @param records 已打标签记录(键:type、remark、channel、category、tags) + * @return JSON 格式的用户提示词 + */ + public String ruleUserPrompt(List> categories, List> tags, + List> records) { + Map variables = new LinkedHashMap<>(); + variables.put("categories", categories); + variables.put("tags", tags); + variables.put("records", records); + return render("ai-rule", "user", variables); + } + + /** + * 渲染提示词模板的指定片段,模板名、片段名与变量由各专用方法提供。 * * @param template 模板名(不含 {@code .tpl} 后缀) + * @param fragment 片段名({@code system} / {@code user} / {@code strictSystem} / {@code newSystem}) * @param variables 模板变量,允许为 null * @return 渲染后的提示词文本 */ - private String render(String template, Map variables) { - Context context = new Context(Locale.CHINA, variables == null ? Collections.emptyMap() : variables); + private String render(String template, String fragment, Map variables) { + Map vars = variables == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(variables); + vars.put("__fragment", fragment); + Context context = new Context(Locale.CHINA, vars); try { return templateEngine.process(template, context); } catch (RuntimeException e) { diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiCategoryServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiCategoryServiceImpl.java index d74c9ca..357d903 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiCategoryServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiCategoryServiceImpl.java @@ -71,7 +71,7 @@ public class AiCategoryServiceImpl implements AiCategoryService { try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), promptTemplateService.categorySystemPrompt(TransactionType.describe()), - text.trim()); + promptTemplateService.categoryUserPrompt(text.trim())); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiChannelServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiChannelServiceImpl.java index 6921075..ac6dac1 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiChannelServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiChannelServiceImpl.java @@ -61,7 +61,7 @@ public class AiChannelServiceImpl implements AiChannelService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - promptTemplateService.channelSystemPrompt(), text.trim()); + promptTemplateService.channelSystemPrompt(), promptTemplateService.channelUserPrompt(text.trim())); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.CHANNEL, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiDecorationServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiDecorationServiceImpl.java index 20d758d..4fb7a5d 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiDecorationServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiDecorationServiceImpl.java @@ -59,7 +59,7 @@ public class AiDecorationServiceImpl implements AiDecorationService { public String suggestEmoji(Long userId, String name, String type) { String trimmed = requireName(name); AiConfig config = requireConfig(userId); - String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type); + String userPrompt = promptTemplateService.decorationEmojiUserPrompt(trimmed, typeLabel(type)); JsonNode root = chatAndParse(userId, config, promptTemplateService.decorationEmojiSystemPrompt(), userPrompt); String emoji = root.path("emoji").asText("").trim(); @@ -78,7 +78,7 @@ public class AiDecorationServiceImpl implements AiDecorationService { public String suggestColor(Long userId, String name) { String trimmed = requireName(name); AiConfig config = requireConfig(userId); - String userPrompt = "标签名称:" + trimmed; + String userPrompt = promptTemplateService.decorationColorUserPrompt(trimmed); JsonNode root = chatAndParse(userId, config, promptTemplateService.decorationColorSystemPrompt(), userPrompt); String color = root.path("color").asText("").trim(); diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiImageRecordServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiImageRecordServiceImpl.java index 0537112..a3f9a61 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiImageRecordServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiImageRecordServiceImpl.java @@ -196,7 +196,7 @@ public class AiImageRecordServiceImpl implements AiImageRecordService { dataUrls.add("data:" + types.get(i).getMimeType() + ";base64," + Base64.getEncoder().encodeToString(images.get(i))); } - String userText = isBlank(hint) ? "请识别这些账单截图中的交易。" : hint.trim(); + String userText = promptTemplateService.recordImageUserPrompt(isBlank(hint) ? null : hint.trim()); long start = System.currentTimeMillis(); log.info("图片记账调用视觉模型: userId={}, 模型={}, 图片数={}", userId, visionModel, images.size()); diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiQueryServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiQueryServiceImpl.java index 31ce147..d91cefc 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiQueryServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiQueryServiceImpl.java @@ -104,7 +104,7 @@ public class AiQueryServiceImpl implements AiQueryService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - systemPrompt, query); + systemPrompt, promptTemplateService.queryUserPrompt(query)); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.QUERY, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRecordServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRecordServiceImpl.java index 763c9f2..6d9950b 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRecordServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRecordServiceImpl.java @@ -93,7 +93,7 @@ public class AiRecordServiceImpl implements AiRecordService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - systemPrompt, text); + systemPrompt, promptTemplateService.recordUserPrompt(text)); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRelationServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRelationServiceImpl.java index 9bc1686..2fe9c47 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRelationServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRelationServiceImpl.java @@ -1,5 +1,10 @@ package cn.rosercode.ledgerly.service.impl; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -96,26 +101,25 @@ public class AiRelationServiceImpl implements AiRelationService { * @return 用户提示词 */ private String buildUserPrompt(AiRelationRequest request) { - StringBuilder builder = new StringBuilder(); - builder.append("关系类型:").append(TransactionRelationType.labelOf(request.getType())).append('\n'); - builder.append("成员账单:").append('\n'); - int index = 1; + List> members = new ArrayList<>(); + int index = 0; for (AiRelationMemberRequest member : request.getMembers()) { if (member == null) { continue; } - if (index > MEMBER_LIMIT) { + if (index >= MEMBER_LIMIT) { break; } - builder.append(index).append(". ") - .append(TransactionType.labelOf(member.getType())).append(" | ") - .append(trim(member.getChannel())).append(" | ") - .append(trim(member.getCategoryName())).append(" | ") - .append(truncate(member.getRemark(), REMARK_TRUNCATE)) - .append('\n'); + Map node = new LinkedHashMap<>(); + node.put("type", TransactionType.labelOf(member.getType())); + node.put("channel", trim(member.getChannel())); + node.put("categoryName", trim(member.getCategoryName())); + node.put("remark", truncate(member.getRemark(), REMARK_TRUNCATE)); + members.add(node); index++; } - return builder.toString(); + return promptTemplateService.relationUserPrompt( + TransactionRelationType.labelOf(request.getType()), members); } /** diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java index 3734cb2..d687151 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java @@ -456,23 +456,24 @@ public class AiRuleServiceImpl implements AiRuleService { Map> tagNamesByTransaction) { Map categoryNameMap = categories.stream() .collect(Collectors.toMap(Category::getId, Category::getName, (a, b) -> a)); - Map payload = new LinkedHashMap<>(); - payload.put("categories", categories.stream().map(category -> { + + List> categoryNodes = categories.stream().map(category -> { Map node = new LinkedHashMap<>(); node.put("id", category.getId()); node.put("name", category.getName()); node.put("type", category.getType()); return node; - }).collect(Collectors.toList())); - payload.put("tags", tags.stream().map(tag -> { + }).collect(Collectors.toList()); + + List> tagNodes = tags.stream().map(tag -> { Map node = new LinkedHashMap<>(); node.put("id", tag.getId()); node.put("name", tag.getName()); return node; - }).collect(Collectors.toList())); + }).collect(Collectors.toList()); // 相同「类型 + 备注 + 渠道 + 标签」的记录只保留一条,降低 token 消耗 - List> records = new ArrayList<>(); + List> recordNodes = new ArrayList<>(); Set seen = new LinkedHashSet<>(); for (Transaction transaction : transactions) { List recordTagNames = tagNamesByTransaction.getOrDefault(transaction.getId(), List.of()); @@ -489,15 +490,10 @@ public class AiRuleServiceImpl implements AiRuleService { node.put("category", transaction.getCategoryId() == null ? null : categoryNameMap.get(transaction.getCategoryId())); node.put("tags", recordTagNames); - records.add(node); + recordNodes.add(node); } - payload.put("records", records); - try { - return objectMapper.writeValueAsString(payload); - } catch (JsonProcessingException e) { - throw new BusinessException("构建 AI 提示词失败: " + e.getMessage()); - } + return promptTemplateService.ruleUserPrompt(categoryNodes, tagNodes, recordNodes); } /** diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java index 00dc122..d162bfb 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSqlQueryServiceImpl.java @@ -112,7 +112,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - systemPrompt, query); + systemPrompt, promptTemplateService.textToSqlUserPrompt(query)); } catch (RuntimeException e) { aiUsageService.recordFailure(userId, AiCallType.SQL, config.getModel(), 0, System.currentTimeMillis() - start, e.getMessage()); diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiStatisticsServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiStatisticsServiceImpl.java index c30dca8..5a96455 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiStatisticsServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiStatisticsServiceImpl.java @@ -116,7 +116,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - systemPrompt, query); + systemPrompt, promptTemplateService.statisticsUserPrompt(query)); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.STATISTICS, config.getModel(), 0, diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiSuggestionServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSuggestionServiceImpl.java index 266e5af..f7cad8d 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiSuggestionServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiSuggestionServiceImpl.java @@ -578,50 +578,51 @@ public class AiSuggestionServiceImpl implements AiSuggestionService { * @param categories 用户分类列表 * @param tags 用户标签列表 * @param transactions 待分析记录列表 + * @param channels 渠道候选列表,可为 null * @return JSON 格式的用户提示词 */ private String buildUserPrompt(List categories, List tags, List transactions, List channels) { - Map payload = new LinkedHashMap<>(); - payload.put("categories", categories.stream().map(category -> { + List> categoryNodes = categories.stream().map(category -> { Map node = new LinkedHashMap<>(); node.put("id", category.getId()); node.put("name", category.getName()); node.put("type", category.getType()); return node; - }).collect(Collectors.toList())); - payload.put("tags", tags.stream().map(tag -> { + }).collect(Collectors.toList()); + + List> tagNodes = tags.stream().map(tag -> { Map node = new LinkedHashMap<>(); node.put("id", tag.getId()); node.put("name", tag.getName()); return node; - }).collect(Collectors.toList())); + }).collect(Collectors.toList()); + // 渠道候选仅在单条建议时下发,批量建议保持原有载荷 + List> channelNodes = null; if (channels != null && !channels.isEmpty()) { - payload.put("channels", channels.stream().map(option -> { + channelNodes = channels.stream().map(option -> { Map node = new LinkedHashMap<>(); node.put("code", option.getCode()); // 内置渠道名称为空时回填枚举中文名,便于模型理解 node.put("name", option.getName() != null ? option.getName() : ChannelType.labelOf(option.getCode())); return node; - }).collect(Collectors.toList())); + }).collect(Collectors.toList()); } + // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息 - payload.put("transactions", transactions.stream().map(transaction -> { + List> transactionNodes = transactions.stream().map(transaction -> { Map node = new LinkedHashMap<>(); node.put("id", transaction.getId()); node.put("type", transaction.getType()); - node.put("time", transaction.getTransactionTime()); + node.put("time", transaction.getTransactionTime() == null ? "" + : transaction.getTransactionTime().toString()); node.put("channel", transaction.getChannel()); node.put("remark", transaction.getRemark()); return node; - }).collect(Collectors.toList())); + }).collect(Collectors.toList()); - try { - return objectMapper.writeValueAsString(payload); - } catch (JsonProcessingException e) { - throw new BusinessException("构建 AI 提示词失败: " + e.getMessage()); - } + return promptTemplateService.suggestionUserPrompt(categoryNodes, tagNodes, channelNodes, transactionNodes); } /** diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiTagServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiTagServiceImpl.java index 7e35efc..039ab32 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiTagServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiTagServiceImpl.java @@ -69,7 +69,7 @@ public class AiTagServiceImpl implements AiTagService { DeepSeekResponse aiResponse; try { aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), - promptTemplateService.tagSystemPrompt(), text.trim()); + promptTemplateService.tagSystemPrompt(), promptTemplateService.tagUserPrompt(text.trim())); } catch (RuntimeException e) { // 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0, diff --git a/src/main/resources/prompts/ai-category-system.tpl b/src/main/resources/prompts/ai-category.tpl similarity index 78% rename from src/main/resources/prompts/ai-category-system.tpl rename to src/main/resources/prompts/ai-category.tpl index 8548346..fa7f5df 100644 --- a/src/main/resources/prompts/ai-category-system.tpl +++ b/src/main/resources/prompts/ai-category.tpl @@ -1,5 +1,11 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求: 1. name 为 2-6 个汉字的分类名称; 2. type 只能是 [(${typeDesc})]; 3. icon 为与该分类最贴切的一个 Emoji; -4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"} \ No newline at end of file +4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${text})] +[/] diff --git a/src/main/resources/prompts/ai-channel-system.tpl b/src/main/resources/prompts/ai-channel.tpl similarity index 81% rename from src/main/resources/prompts/ai-channel-system.tpl rename to src/main/resources/prompts/ai-channel.tpl index e837738..043e020 100644 --- a/src/main/resources/prompts/ai-channel-system.tpl +++ b/src/main/resources/prompts/ai-channel.tpl @@ -1,3 +1,9 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的交易渠道助手。请根据用户对交易渠道用途的自然语言描述,生成一个简洁的记账渠道名称。要求: 1. name 为 2-6 个汉字的渠道名称,通常是支付平台、银行或支付方式(如「招商银行」「云闪付」); 2. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"招商银行","reason":"简要中文理由"} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${text})] +[/] diff --git a/src/main/resources/prompts/ai-config-test-system.tpl b/src/main/resources/prompts/ai-config-test-system.tpl deleted file mode 100644 index 2e5156b..0000000 --- a/src/main/resources/prompts/ai-config-test-system.tpl +++ /dev/null @@ -1 +0,0 @@ -你是一个测试助手,请只输出 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 deleted file mode 100644 index cbeac32..0000000 --- a/src/main/resources/prompts/ai-config-test-user.tpl +++ /dev/null @@ -1 +0,0 @@ -请返回 {"ok":true} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-config-test.tpl b/src/main/resources/prompts/ai-config-test.tpl new file mode 100644 index 0000000..b33e362 --- /dev/null +++ b/src/main/resources/prompts/ai-config-test.tpl @@ -0,0 +1,7 @@ +[# th:if="${__fragment == 'system'}"] +你是一个测试助手,请只输出 JSON。 +[/] + +[# th:if="${__fragment == 'user'}"] +请返回 {"ok":true} +[/] diff --git a/src/main/resources/prompts/ai-decoration-color-system.tpl b/src/main/resources/prompts/ai-decoration-color.tpl similarity index 65% rename from src/main/resources/prompts/ai-decoration-color-system.tpl rename to src/main/resources/prompts/ai-decoration-color.tpl index 9a6d390..6c10374 100644 --- a/src/main/resources/prompts/ai-decoration-color-system.tpl +++ b/src/main/resources/prompts/ai-decoration-color.tpl @@ -1,2 +1,8 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。 -只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"} \ No newline at end of file +只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"} +[/] + +[# th:if="${__fragment == 'user'}"] +标签名称:[(${name})] +[/] diff --git a/src/main/resources/prompts/ai-decoration-emoji-system.tpl b/src/main/resources/prompts/ai-decoration-emoji.tpl similarity index 59% rename from src/main/resources/prompts/ai-decoration-emoji-system.tpl rename to src/main/resources/prompts/ai-decoration-emoji.tpl index fc8a726..50b5742 100644 --- a/src/main/resources/prompts/ai-decoration-emoji-system.tpl +++ b/src/main/resources/prompts/ai-decoration-emoji.tpl @@ -1,2 +1,8 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。 -只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"} \ No newline at end of file +只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"} +[/] + +[# th:if="${__fragment == 'user'}"] +分类名称:[(${name})];收支类型:[(${typeLabel})] +[/] diff --git a/src/main/resources/prompts/ai-query-system.tpl b/src/main/resources/prompts/ai-query.tpl similarity index 88% rename from src/main/resources/prompts/ai-query-system.tpl rename to src/main/resources/prompts/ai-query.tpl index a0c220f..f10ac55 100644 --- a/src/main/resources/prompts/ai-query-system.tpl +++ b/src/main/resources/prompts/ai-query.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。 可用的收支类型:[(${typeDesc})]。 可用的交易渠道:[(${channelDesc})]。 @@ -9,4 +10,9 @@ 分类和标签只能使用上面给出的名称。 用户表达「没有标签/无标签」时,将 untagged 置为 true;此时 tagNames 必须为空数组,两者互斥。 今天是 [(${today})]。 -输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"untagged":false,"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 +输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"untagged":false,"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${query})] +[/] diff --git a/src/main/resources/prompts/ai-record-image-system.tpl b/src/main/resources/prompts/ai-record-image.tpl similarity index 91% rename from src/main/resources/prompts/ai-record-image-system.tpl rename to src/main/resources/prompts/ai-record-image.tpl index 173aa4c..a053ba4 100644 --- a/src/main/resources/prompts/ai-record-image-system.tpl +++ b/src/main/resources/prompts/ai-record-image.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个记账助手。用户可能上传一张或多张账单或支付截图,图片按上传顺序从 1 开始编号。请识别所有图片中的每一笔交易,解析为结构化记录,只输出 JSON,不要输出任何多余内容。 可用的收支类型:[(${typeDesc})]。 可用的交易渠道:[(${channelDesc})]。 @@ -18,3 +19,8 @@ 12. rawData 为该笔交易在图片中出现的全部可见字段组成的对象,键使用图片中的原始列名(如「交易时间」「交易对方」「商品说明」「收/支」「金额」「支付方式」「交易状态」「交易订单号」「商户单号」「备注」等),值为对应文本;尽可能完整提取,图片中不存在的字段省略。 当前时间:[(${now})]。 输出格式:{"summary":"简要说明识别到的账单内容","records":[{"imageIndex":1,"type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"rawData":{"交易时间":"2026-09-20 12:30:00","交易对方":"味香园餐厅","商品说明":"午餐","收/支":"支出","金额":"-35.50","支付方式":"支付宝","交易状态":"交易成功","交易订单号":"2026092022001234567890"}}],"unmatched":[]} +[/] + +[# th:if="${__fragment == 'user'}"] +[# th:if="${#strings.isEmpty(hint)}"]请识别这些账单截图中的交易。[/][# th:unless="${#strings.isEmpty(hint)}"][(${hint})][/] +[/] diff --git a/src/main/resources/prompts/ai-record-system.tpl b/src/main/resources/prompts/ai-record.tpl similarity index 87% rename from src/main/resources/prompts/ai-record-system.tpl rename to src/main/resources/prompts/ai-record.tpl index e93fe24..78c8213 100644 --- a/src/main/resources/prompts/ai-record-system.tpl +++ b/src/main/resources/prompts/ai-record.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。 可用的收支类型:[(${typeDesc})]。 可用的交易渠道:[(${channelDesc})]。 @@ -13,4 +14,9 @@ 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 +输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${text})] +[/] diff --git a/src/main/resources/prompts/ai-relation-system.tpl b/src/main/resources/prompts/ai-relation.tpl similarity index 80% rename from src/main/resources/prompts/ai-relation-system.tpl rename to src/main/resources/prompts/ai-relation.tpl index 8c89298..d286f56 100644 --- a/src/main/resources/prompts/ai-relation-system.tpl +++ b/src/main/resources/prompts/ai-relation.tpl @@ -1,6 +1,15 @@ +[# th:if="${__fragment == 'system'}"] 你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求: 1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」); 2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还); 3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还; 4. 不要提及或推算任何金额; 5. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通卡充值","remark":"充值后分次乘车扣减","reason":"简要中文理由"} +[/] + +[# th:if="${__fragment == 'user'}"] +关系类型:[(${typeLabel})] +成员账单: +[# th:each="m,it : ${members}"][(${it.count})]. [(${m.type})] | [(${m.channel})] | [(${m.categoryName})] | [(${m.remark})] +[/] +[/] diff --git a/src/main/resources/prompts/ai-rule-system.tpl b/src/main/resources/prompts/ai-rule.tpl similarity index 56% rename from src/main/resources/prompts/ai-rule-system.tpl rename to src/main/resources/prompts/ai-rule.tpl index 94410aa..a596886 100644 --- a/src/main/resources/prompts/ai-rule-system.tpl +++ b/src/main/resources/prompts/ai-rule.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。 每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。 规则字段说明: @@ -10,4 +11,9 @@ - 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 +输出格式:{"summary":"简要中文摘要","rules":[{"name":"...","type":"EXPENSE","keyword":"星巴克","categoryId":12,"newCategory":null,"tagIds":[3],"newTags":[],"reason":"..."}]} +[/] + +[# th:if="${__fragment == 'user'}"] +{"categories":[[# th:each="c,it : ${categories}"][# th:if="${it.index > 0}"],[/]{"id":[(${c.id})],"name":"[(${#strings.escapeJavaScript(c.name)})]","type":"[(${#strings.escapeJavaScript(c.type)})]"}[/]],"tags":[[# th:each="t,it : ${tags}"][# th:if="${it.index > 0}"],[/]{"id":[(${t.id})],"name":"[(${#strings.escapeJavaScript(t.name)})]"}[/]],"records":[[# th:each="r,it : ${records}"][# th:if="${it.index > 0}"],[/]{"type":"[(${#strings.escapeJavaScript(r.type)})]","remark":"[(${#strings.escapeJavaScript(r.remark)})]","channel":"[(${#strings.escapeJavaScript(r.channel)})]","category":[# th:if="${r.category == null}"]null[/][# th:unless="${r.category == null}"]"[(${#strings.escapeJavaScript(r.category)})]"[/],"tags":[[# th:each="tg,ti : ${r.tags}"][# th:if="${ti.index > 0}"],[/]"[(${#strings.escapeJavaScript(tg)})]"[/]]}[/]]} +[/] diff --git a/src/main/resources/prompts/ai-sql-system.tpl b/src/main/resources/prompts/ai-sql.tpl similarity index 95% rename from src/main/resources/prompts/ai-sql-system.tpl rename to src/main/resources/prompts/ai-sql.tpl index 7dea102..5e23606 100644 --- a/src/main/resources/prompts/ai-sql-system.tpl +++ b/src/main/resources/prompts/ai-sql.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个记账系统的 SQL 生成助手。请根据用户的自然语言,生成一条只读的 SELECT 查询语句,只输出 JSON,不要输出任何多余内容。 当前数据库方言:[(${dialect})]。今天是 [(${today})]。 @@ -17,3 +18,8 @@ [(${schema})] 输出格式:{"sql":"生成的 SELECT 语句","explanation":"查询意图说明","chartType":"SCALAR"} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${query})] +[/] diff --git a/src/main/resources/prompts/ai-statistics-system.tpl b/src/main/resources/prompts/ai-statistics.tpl similarity index 90% rename from src/main/resources/prompts/ai-statistics-system.tpl rename to src/main/resources/prompts/ai-statistics.tpl index ca723b4..f2a2693 100644 --- a/src/main/resources/prompts/ai-statistics-system.tpl +++ b/src/main/resources/prompts/ai-statistics.tpl @@ -1,3 +1,4 @@ +[# th:if="${__fragment == 'system'}"] 你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。 可用的收支类型:[(${typeDesc})]。 可用的交易渠道:[(${channelDesc})]。 @@ -11,4 +12,9 @@ statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全 分类和标签只能使用上面给出的名称。 用户表达「未分类」时,将 uncategorized 置为 true;表达「没有标签/无标签」时,将 untagged 置为 true,此时 tagNames 必须为空数组,两者互斥。 今天是 [(${today})]。 -输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"uncategorized":false,"untagged":false,"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 +输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"uncategorized":false,"untagged":false,"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${query})] +[/] diff --git a/src/main/resources/prompts/ai-suggestion-new-system.tpl b/src/main/resources/prompts/ai-suggestion-new-system.tpl deleted file mode 100644 index 917fe90..0000000 --- a/src/main/resources/prompts/ai-suggestion-new-system.tpl +++ /dev/null @@ -1,9 +0,0 @@ -你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。要求: -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 deleted file mode 100644 index 5c5244b..0000000 --- a/src/main/resources/prompts/ai-suggestion-strict-system.tpl +++ /dev/null @@ -1,6 +0,0 @@ -你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。要求: -1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类); -2. 只能使用给定列表中的 ID,不要编造; -3. 标签可多选,也可为空数组; -4. 若输入包含 channels 候选列表,请结合记录的类型、备注、交易渠道等信息选择最合适的渠道,channel 只能取候选列表中的 code,无法判断时返回 null; -5. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"channel":"渠道编码或null","reason":"简要中文理由"}]} \ No newline at end of file diff --git a/src/main/resources/prompts/ai-suggestion.tpl b/src/main/resources/prompts/ai-suggestion.tpl new file mode 100644 index 0000000..895542f --- /dev/null +++ b/src/main/resources/prompts/ai-suggestion.tpl @@ -0,0 +1,24 @@ +[# th:if="${__fragment == 'strictSystem'}"] +你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。要求: +1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类); +2. 只能使用给定列表中的 ID,不要编造; +3. 标签可多选,也可为空数组; +4. 若输入包含 channels 候选列表,请结合记录的类型、备注、交易渠道等信息选择最合适的渠道,channel 只能取候选列表中的 code,无法判断时返回 null; +5. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"channel":"渠道编码或null","reason":"简要中文理由"}]} +[/] + +[# th:if="${__fragment == 'newSystem'}"] +你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。要求: +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。[/] +[/] + +[# th:if="${__fragment == 'user'}"] +{"categories":[[# th:each="c,it : ${categories}"][# th:if="${it.index > 0}"],[/]{"id":[(${c.id})],"name":"[(${#strings.escapeJavaScript(c.name)})]","type":"[(${#strings.escapeJavaScript(c.type)})]"}[/]],"tags":[[# th:each="t,it : ${tags}"][# th:if="${it.index > 0}"],[/]{"id":[(${t.id})],"name":"[(${#strings.escapeJavaScript(t.name)})]"}[/]][# th:if="${channels != null and !channels.isEmpty()}"],"channels":[[# th:each="ch,it : ${channels}"][# th:if="${it.index > 0}"],[/]{"code":"[(${#strings.escapeJavaScript(ch.code)})]","name":"[(${#strings.escapeJavaScript(ch.name)})]"}[/]][/],"transactions":[[# th:each="tx,it : ${transactions}"][# th:if="${it.index > 0}"],[/]{"id":[(${tx.id})],"type":"[(${#strings.escapeJavaScript(tx.type)})]","time":"[(${#strings.escapeJavaScript(tx.time)})]","channel":"[(${#strings.escapeJavaScript(tx.channel)})]","remark":"[(${#strings.escapeJavaScript(tx.remark)})]"}[/]]} +[/] diff --git a/src/main/resources/prompts/ai-tag-system.tpl b/src/main/resources/prompts/ai-tag.tpl similarity index 80% rename from src/main/resources/prompts/ai-tag-system.tpl rename to src/main/resources/prompts/ai-tag.tpl index c278203..846f2d4 100644 --- a/src/main/resources/prompts/ai-tag-system.tpl +++ b/src/main/resources/prompts/ai-tag.tpl @@ -1,4 +1,10 @@ +[# th:if="${__fragment == 'system'}"] 你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求: 1. name 为 2-6 个汉字的标签名称; 2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色; -3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"} \ No newline at end of file +3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"} +[/] + +[# th:if="${__fragment == 'user'}"] +[(${text})] +[/]