refactor: AI 提示词统一抽取为 Thymeleaf 模板

将各 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,语义等价。
This commit is contained in:
2026-09-30 19:00:08 +08:00
parent 007e6727a6
commit 338c72f7a5
31 changed files with 364 additions and 113 deletions
@@ -1,7 +1,7 @@
package cn.rosercode.ledgerly.prompt; package cn.rosercode.ledgerly.prompt;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@@ -15,8 +15,9 @@ import cn.rosercode.ledgerly.common.BusinessException;
* AI 提示词模板渲染组件。 * AI 提示词模板渲染组件。
* *
* <p>统一从 {@code classpath:/prompts/} 加载 Thymeleaf 文本模板(TEXT 模式)。 * <p>统一从 {@code classpath:/prompts/} 加载 Thymeleaf 文本模板(TEXT 模式)。
* 每个模板对应一个语义化方法,调用方只需传入该模板所需数据、无需关心模板名, * 每个 AI 功能的系统提示词与用户提示词位于同一模板文件的不同片段
* 使提示词与 Java 代码解耦、便于集中维护。模板解析规则见 * ({@code system} / {@code user}),每个片段对应一个语义化方法,调用方只需传入该片段
* 所需数据、无需关心模板名,使提示词与 Java 代码解耦、便于集中维护。模板解析规则见
* {@code application.yml} 的 {@code spring.thymeleaf} 配置。</p> * {@code application.yml} 的 {@code spring.thymeleaf} 配置。</p>
* *
* @date 2026-09-21 10:58:05 * @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 typeDesc 收支类型说明
* @param channelDesc 交易渠道说明 * @param channelDesc 交易渠道说明
@@ -56,11 +57,11 @@ public class PromptTemplateService {
variables.put("tagNames", tagNames); variables.put("tagNames", tagNames);
variables.put("now", now); variables.put("now", now);
variables.put("exampleTime", exampleTime); 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 typeDesc 收支类型说明
* @param channelDesc 交易渠道说明 * @param channelDesc 交易渠道说明
@@ -79,11 +80,11 @@ public class PromptTemplateService {
variables.put("tagNames", tagNames); variables.put("tagNames", tagNames);
variables.put("now", now); variables.put("now", now);
variables.put("exampleTime", exampleTime); 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 typeDesc 收支类型说明
* @param channelDesc 交易渠道说明 * @param channelDesc 交易渠道说明
@@ -100,11 +101,11 @@ public class PromptTemplateService {
variables.put("categoryNames", categoryNames); variables.put("categoryNames", categoryNames);
variables.put("tagNames", tagNames); variables.put("tagNames", tagNames);
variables.put("today", today); 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 typeDesc 收支类型说明
* @param channelDesc 交易渠道说明 * @param channelDesc 交易渠道说明
@@ -121,11 +122,11 @@ public class PromptTemplateService {
variables.put("categoryNames", categoryNames); variables.put("categoryNames", categoryNames);
variables.put("tagNames", tagNames); variables.put("tagNames", tagNames);
variables.put("today", today); 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 dialect 当前数据库方言(MySQL / SQLite)
* @param today 当天日期(yyyy-MM-dd) * @param today 当天日期(yyyy-MM-dd)
@@ -137,119 +138,279 @@ public class PromptTemplateService {
variables.put("dialect", dialect); variables.put("dialect", dialect);
variables.put("today", today); variables.put("today", today);
variables.put("schema", schema); 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 收支类型说明 * @param typeDesc 收支类型说明
* @return 系统提示词 * @return 系统提示词
*/ */
public String categorySystemPrompt(String typeDesc) { 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 系统提示词 * @return 系统提示词
*/ */
public String tagSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String channelSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String relationSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String decorationEmojiSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String decorationColorSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String suggestionStrictSystemPrompt() { 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),决定追加的限定说明 * @param scope 建议范围(CATEGORY / TAG / BOTH),决定追加的限定说明
* @return 系统提示词 * @return 系统提示词
*/ */
public String suggestionNewSystemPrompt(String scope) { 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 系统提示词 * @return 系统提示词
*/ */
public String ruleSystemPrompt() { 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 系统提示词 * @return 系统提示词
*/ */
public String configTestSystemPrompt() { 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 用户提示词 * @return 用户提示词
*/ */
public String configTestUserPrompt() { 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<String, Object> 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<String, Object> 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<Map<String, Object>> members) {
Map<String, Object> 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<Map<String, Object>> categories, List<Map<String, Object>> tags,
List<Map<String, Object>> channels, List<Map<String, Object>> transactions) {
Map<String, Object> 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<Map<String, Object>> categories, List<Map<String, Object>> tags,
List<Map<String, Object>> records) {
Map<String, Object> 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 template 模板名(不含 {@code .tpl} 后缀)
* @param fragment 片段名({@code system} / {@code user} / {@code strictSystem} / {@code newSystem})
* @param variables 模板变量,允许为 null * @param variables 模板变量,允许为 null
* @return 渲染后的提示词文本 * @return 渲染后的提示词文本
*/ */
private String render(String template, Map<String, Object> variables) { private String render(String template, String fragment, Map<String, Object> variables) {
Context context = new Context(Locale.CHINA, variables == null ? Collections.emptyMap() : variables); Map<String, Object> vars = variables == null
? new LinkedHashMap<>() : new LinkedHashMap<>(variables);
vars.put("__fragment", fragment);
Context context = new Context(Locale.CHINA, vars);
try { try {
return templateEngine.process(template, context); return templateEngine.process(template, context);
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -71,7 +71,7 @@ public class AiCategoryServiceImpl implements AiCategoryService {
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
promptTemplateService.categorySystemPrompt(TransactionType.describe()), promptTemplateService.categorySystemPrompt(TransactionType.describe()),
text.trim()); promptTemplateService.categoryUserPrompt(text.trim()));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
@@ -61,7 +61,7 @@ public class AiChannelServiceImpl implements AiChannelService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
promptTemplateService.channelSystemPrompt(), text.trim()); promptTemplateService.channelSystemPrompt(), promptTemplateService.channelUserPrompt(text.trim()));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.CHANNEL, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.CHANNEL, config.getModel(), 0,
@@ -59,7 +59,7 @@ public class AiDecorationServiceImpl implements AiDecorationService {
public String suggestEmoji(Long userId, String name, String type) { public String suggestEmoji(Long userId, String name, String type) {
String trimmed = requireName(name); String trimmed = requireName(name);
AiConfig config = requireConfig(userId); AiConfig config = requireConfig(userId);
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type); String userPrompt = promptTemplateService.decorationEmojiUserPrompt(trimmed, typeLabel(type));
JsonNode root = chatAndParse(userId, config, JsonNode root = chatAndParse(userId, config,
promptTemplateService.decorationEmojiSystemPrompt(), userPrompt); promptTemplateService.decorationEmojiSystemPrompt(), userPrompt);
String emoji = root.path("emoji").asText("").trim(); String emoji = root.path("emoji").asText("").trim();
@@ -78,7 +78,7 @@ public class AiDecorationServiceImpl implements AiDecorationService {
public String suggestColor(Long userId, String name) { public String suggestColor(Long userId, String name) {
String trimmed = requireName(name); String trimmed = requireName(name);
AiConfig config = requireConfig(userId); AiConfig config = requireConfig(userId);
String userPrompt = "标签名称:" + trimmed; String userPrompt = promptTemplateService.decorationColorUserPrompt(trimmed);
JsonNode root = chatAndParse(userId, config, JsonNode root = chatAndParse(userId, config,
promptTemplateService.decorationColorSystemPrompt(), userPrompt); promptTemplateService.decorationColorSystemPrompt(), userPrompt);
String color = root.path("color").asText("").trim(); String color = root.path("color").asText("").trim();
@@ -196,7 +196,7 @@ public class AiImageRecordServiceImpl implements AiImageRecordService {
dataUrls.add("data:" + types.get(i).getMimeType() + ";base64," dataUrls.add("data:" + types.get(i).getMimeType() + ";base64,"
+ Base64.getEncoder().encodeToString(images.get(i))); + 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(); long start = System.currentTimeMillis();
log.info("图片记账调用视觉模型: userId={}, 模型={}, 图片数={}", userId, visionModel, images.size()); log.info("图片记账调用视觉模型: userId={}, 模型={}, 图片数={}", userId, visionModel, images.size());
@@ -104,7 +104,7 @@ public class AiQueryServiceImpl implements AiQueryService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, query); systemPrompt, promptTemplateService.queryUserPrompt(query));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.QUERY, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.QUERY, config.getModel(), 0,
@@ -93,7 +93,7 @@ public class AiRecordServiceImpl implements AiRecordService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, text); systemPrompt, promptTemplateService.recordUserPrompt(text));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0,
@@ -1,5 +1,10 @@
package cn.rosercode.ledgerly.service.impl; 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -96,26 +101,25 @@ public class AiRelationServiceImpl implements AiRelationService {
* @return 用户提示词 * @return 用户提示词
*/ */
private String buildUserPrompt(AiRelationRequest request) { private String buildUserPrompt(AiRelationRequest request) {
StringBuilder builder = new StringBuilder(); List<Map<String, Object>> members = new ArrayList<>();
builder.append("关系类型:").append(TransactionRelationType.labelOf(request.getType())).append('\n'); int index = 0;
builder.append("成员账单:").append('\n');
int index = 1;
for (AiRelationMemberRequest member : request.getMembers()) { for (AiRelationMemberRequest member : request.getMembers()) {
if (member == null) { if (member == null) {
continue; continue;
} }
if (index > MEMBER_LIMIT) { if (index >= MEMBER_LIMIT) {
break; break;
} }
builder.append(index).append(". ") Map<String, Object> node = new LinkedHashMap<>();
.append(TransactionType.labelOf(member.getType())).append(" | ") node.put("type", TransactionType.labelOf(member.getType()));
.append(trim(member.getChannel())).append(" | ") node.put("channel", trim(member.getChannel()));
.append(trim(member.getCategoryName())).append(" | ") node.put("categoryName", trim(member.getCategoryName()));
.append(truncate(member.getRemark(), REMARK_TRUNCATE)) node.put("remark", truncate(member.getRemark(), REMARK_TRUNCATE));
.append('\n'); members.add(node);
index++; index++;
} }
return builder.toString(); return promptTemplateService.relationUserPrompt(
TransactionRelationType.labelOf(request.getType()), members);
} }
/** /**
@@ -456,23 +456,24 @@ public class AiRuleServiceImpl implements AiRuleService {
Map<Long, List<String>> tagNamesByTransaction) { Map<Long, List<String>> tagNamesByTransaction) {
Map<Long, String> categoryNameMap = categories.stream() Map<Long, String> categoryNameMap = categories.stream()
.collect(Collectors.toMap(Category::getId, Category::getName, (a, b) -> a)); .collect(Collectors.toMap(Category::getId, Category::getName, (a, b) -> a));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("categories", categories.stream().map(category -> { List<Map<String, Object>> categoryNodes = categories.stream().map(category -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("id", category.getId()); node.put("id", category.getId());
node.put("name", category.getName()); node.put("name", category.getName());
node.put("type", category.getType()); node.put("type", category.getType());
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
payload.put("tags", tags.stream().map(tag -> {
List<Map<String, Object>> tagNodes = tags.stream().map(tag -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("id", tag.getId()); node.put("id", tag.getId());
node.put("name", tag.getName()); node.put("name", tag.getName());
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
// 相同「类型 + 备注 + 渠道 + 标签」的记录只保留一条,降低 token 消耗 // 相同「类型 + 备注 + 渠道 + 标签」的记录只保留一条,降低 token 消耗
List<Map<String, Object>> records = new ArrayList<>(); List<Map<String, Object>> recordNodes = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>(); Set<String> seen = new LinkedHashSet<>();
for (Transaction transaction : transactions) { for (Transaction transaction : transactions) {
List<String> recordTagNames = tagNamesByTransaction.getOrDefault(transaction.getId(), List.of()); List<String> recordTagNames = tagNamesByTransaction.getOrDefault(transaction.getId(), List.of());
@@ -489,15 +490,10 @@ public class AiRuleServiceImpl implements AiRuleService {
node.put("category", transaction.getCategoryId() == null node.put("category", transaction.getCategoryId() == null
? null : categoryNameMap.get(transaction.getCategoryId())); ? null : categoryNameMap.get(transaction.getCategoryId()));
node.put("tags", recordTagNames); node.put("tags", recordTagNames);
records.add(node); recordNodes.add(node);
} }
payload.put("records", records);
try { return promptTemplateService.ruleUserPrompt(categoryNodes, tagNodes, recordNodes);
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new BusinessException("构建 AI 提示词失败: " + e.getMessage());
}
} }
/** /**
@@ -112,7 +112,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, query); systemPrompt, promptTemplateService.textToSqlUserPrompt(query));
} catch (RuntimeException e) { } catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.SQL, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.SQL, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage()); System.currentTimeMillis() - start, e.getMessage());
@@ -116,7 +116,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, query); systemPrompt, promptTemplateService.statisticsUserPrompt(query));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.STATISTICS, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.STATISTICS, config.getModel(), 0,
@@ -578,50 +578,51 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
* @param categories 用户分类列表 * @param categories 用户分类列表
* @param tags 用户标签列表 * @param tags 用户标签列表
* @param transactions 待分析记录列表 * @param transactions 待分析记录列表
* @param channels 渠道候选列表,可为 null
* @return JSON 格式的用户提示词 * @return JSON 格式的用户提示词
*/ */
private String buildUserPrompt(List<Category> categories, List<Tag> tags, List<Transaction> transactions, private String buildUserPrompt(List<Category> categories, List<Tag> tags, List<Transaction> transactions,
List<ChannelOptionVO> channels) { List<ChannelOptionVO> channels) {
Map<String, Object> payload = new LinkedHashMap<>(); List<Map<String, Object>> categoryNodes = categories.stream().map(category -> {
payload.put("categories", categories.stream().map(category -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("id", category.getId()); node.put("id", category.getId());
node.put("name", category.getName()); node.put("name", category.getName());
node.put("type", category.getType()); node.put("type", category.getType());
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
payload.put("tags", tags.stream().map(tag -> {
List<Map<String, Object>> tagNodes = tags.stream().map(tag -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("id", tag.getId()); node.put("id", tag.getId());
node.put("name", tag.getName()); node.put("name", tag.getName());
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
// 渠道候选仅在单条建议时下发,批量建议保持原有载荷 // 渠道候选仅在单条建议时下发,批量建议保持原有载荷
List<Map<String, Object>> channelNodes = null;
if (channels != null && !channels.isEmpty()) { if (channels != null && !channels.isEmpty()) {
payload.put("channels", channels.stream().map(option -> { channelNodes = channels.stream().map(option -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("code", option.getCode()); node.put("code", option.getCode());
// 内置渠道名称为空时回填枚举中文名,便于模型理解 // 内置渠道名称为空时回填枚举中文名,便于模型理解
node.put("name", option.getName() != null ? option.getName() : ChannelType.labelOf(option.getCode())); node.put("name", option.getName() != null ? option.getName() : ChannelType.labelOf(option.getCode()));
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
} }
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息 // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
payload.put("transactions", transactions.stream().map(transaction -> { List<Map<String, Object>> transactionNodes = transactions.stream().map(transaction -> {
Map<String, Object> node = new LinkedHashMap<>(); Map<String, Object> node = new LinkedHashMap<>();
node.put("id", transaction.getId()); node.put("id", transaction.getId());
node.put("type", transaction.getType()); 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("channel", transaction.getChannel());
node.put("remark", transaction.getRemark()); node.put("remark", transaction.getRemark());
return node; return node;
}).collect(Collectors.toList())); }).collect(Collectors.toList());
try { return promptTemplateService.suggestionUserPrompt(categoryNodes, tagNodes, channelNodes, transactionNodes);
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new BusinessException("构建 AI 提示词失败: " + e.getMessage());
}
} }
/** /**
@@ -69,7 +69,7 @@ public class AiTagServiceImpl implements AiTagService {
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
promptTemplateService.tagSystemPrompt(), text.trim()); promptTemplateService.tagSystemPrompt(), promptTemplateService.tagUserPrompt(text.trim()));
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 // 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0, aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
@@ -1,5 +1,11 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求: 你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求:
1. name 为 2-6 个汉字的分类名称; 1. name 为 2-6 个汉字的分类名称;
2. type 只能是 [(${typeDesc})]; 2. type 只能是 [(${typeDesc})];
3. icon 为与该分类最贴切的一个 Emoji; 3. icon 为与该分类最贴切的一个 Emoji;
4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"} 4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"}
[/]
[# th:if="${__fragment == 'user'}"]
[(${text})]
[/]
@@ -1,3 +1,9 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的交易渠道助手。请根据用户对交易渠道用途的自然语言描述,生成一个简洁的记账渠道名称。要求: 你是一个个人记账应用的交易渠道助手。请根据用户对交易渠道用途的自然语言描述,生成一个简洁的记账渠道名称。要求:
1. name 为 2-6 个汉字的渠道名称,通常是支付平台、银行或支付方式(如「招商银行」「云闪付」); 1. name 为 2-6 个汉字的渠道名称,通常是支付平台、银行或支付方式(如「招商银行」「云闪付」);
2. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"招商银行","reason":"简要中文理由"} 2. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"招商银行","reason":"简要中文理由"}
[/]
[# th:if="${__fragment == 'user'}"]
[(${text})]
[/]
@@ -1 +0,0 @@
你是一个测试助手,请只输出 JSON。
@@ -1 +0,0 @@
请返回 {"ok":true}
@@ -0,0 +1,7 @@
[# th:if="${__fragment == 'system'}"]
你是一个测试助手,请只输出 JSON。
[/]
[# th:if="${__fragment == 'user'}"]
请返回 {"ok":true}
[/]
@@ -1,2 +1,8 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。 你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。
只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"} 只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"}
[/]
[# th:if="${__fragment == 'user'}"]
标签名称:[(${name})]
[/]
@@ -1,2 +1,8 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。 你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。
只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"} 只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"}
[/]
[# th:if="${__fragment == 'user'}"]
分类名称:[(${name})];收支类型:[(${typeLabel})]
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。 你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。 可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。 可用的交易渠道:[(${channelDesc})]。
@@ -10,3 +11,8 @@
用户表达「没有标签/无标签」时,将 untagged 置为 true;此时 tagNames 必须为空数组,两者互斥。 用户表达「没有标签/无标签」时,将 untagged 置为 true;此时 tagNames 必须为空数组,两者互斥。
今天是 [(${today})]。 今天是 [(${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":[]} 输出格式:{"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})]
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个记账助手。用户可能上传一张或多张账单或支付截图,图片按上传顺序从 1 开始编号。请识别所有图片中的每一笔交易,解析为结构化记录,只输出 JSON,不要输出任何多余内容。 你是一个记账助手。用户可能上传一张或多张账单或支付截图,图片按上传顺序从 1 开始编号。请识别所有图片中的每一笔交易,解析为结构化记录,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。 可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。 可用的交易渠道:[(${channelDesc})]。
@@ -18,3 +19,8 @@
12. rawData 为该笔交易在图片中出现的全部可见字段组成的对象,键使用图片中的原始列名(如「交易时间」「交易对方」「商品说明」「收/支」「金额」「支付方式」「交易状态」「交易订单号」「商户单号」「备注」等),值为对应文本;尽可能完整提取,图片中不存在的字段省略。 12. rawData 为该笔交易在图片中出现的全部可见字段组成的对象,键使用图片中的原始列名(如「交易时间」「交易对方」「商品说明」「收/支」「金额」「支付方式」「交易状态」「交易订单号」「商户单号」「备注」等),值为对应文本;尽可能完整提取,图片中不存在的字段省略。
当前时间:[(${now})]。 当前时间:[(${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":[]} 输出格式:{"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})][/]
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。 你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。 可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。 可用的交易渠道:[(${channelDesc})]。
@@ -14,3 +15,8 @@
8. remark 给出简洁备注。 8. remark 给出简洁备注。
当前时间:[(${now})]。 当前时间:[(${now})]。
输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]} 输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]}
[/]
[# th:if="${__fragment == 'user'}"]
[(${text})]
[/]
@@ -1,6 +1,15 @@
[# th:if="${__fragment == 'system'}"]
你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求: 你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求:
1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」); 1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」);
2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还); 2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还);
3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还; 3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还;
4. 不要提及或推算任何金额; 4. 不要提及或推算任何金额;
5. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通卡充值","remark":"充值后分次乘车扣减","reason":"简要中文理由"} 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})]
[/]
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。 你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。
每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。 每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。
规则字段说明: 规则字段说明:
@@ -11,3 +12,8 @@
- reason:简要中文理由。 - reason:简要中文理由。
优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。 优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。
输出格式:{"summary":"简要中文摘要","rules":[{"name":"...","type":"EXPENSE","keyword":"星巴克","categoryId":12,"newCategory":null,"tagIds":[3],"newTags":[],"reason":"..."}]} 输出格式:{"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)})]"[/]]}[/]]}
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个记账系统的 SQL 生成助手。请根据用户的自然语言,生成一条只读的 SELECT 查询语句,只输出 JSON,不要输出任何多余内容。 你是一个记账系统的 SQL 生成助手。请根据用户的自然语言,生成一条只读的 SELECT 查询语句,只输出 JSON,不要输出任何多余内容。
当前数据库方言:[(${dialect})]。今天是 [(${today})]。 当前数据库方言:[(${dialect})]。今天是 [(${today})]。
@@ -17,3 +18,8 @@
[(${schema})] [(${schema})]
输出格式:{"sql":"生成的 SELECT 语句","explanation":"查询意图说明","chartType":"SCALAR"} 输出格式:{"sql":"生成的 SELECT 语句","explanation":"查询意图说明","chartType":"SCALAR"}
[/]
[# th:if="${__fragment == 'user'}"]
[(${query})]
[/]
@@ -1,3 +1,4 @@
[# th:if="${__fragment == 'system'}"]
你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。 你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。 可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。 可用的交易渠道:[(${channelDesc})]。
@@ -12,3 +13,8 @@ statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全
用户表达「未分类」时,将 uncategorized 置为 true;表达「没有标签/无标签」时,将 untagged 置为 true,此时 tagNames 必须为空数组,两者互斥。 用户表达「未分类」时,将 uncategorized 置为 true;表达「没有标签/无标签」时,将 untagged 置为 true,此时 tagNames 必须为空数组,两者互斥。
今天是 [(${today})]。 今天是 [(${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":[]} 输出格式:{"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})]
[/]
@@ -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。[/]
@@ -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":"简要中文理由"}]}
@@ -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)})]"}[/]]}
[/]
@@ -1,4 +1,10 @@
[# th:if="${__fragment == 'system'}"]
你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求: 你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求:
1. name 为 2-6 个汉字的标签名称; 1. name 为 2-6 个汉字的标签名称;
2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色; 2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;
3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"} 3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"}
[/]
[# th:if="${__fragment == 'user'}"]
[(${text})]
[/]