refactor: AI 提示词外置为 Thymeleaf 模板并集中加载

- 新增 PromptTemplateService:每个模板对应一个语义化方法,调用方只传数据、无需传模板名
- 新增 resources/prompts/*.tpl 共 12 个提示词模板(TEXT 模式)
- application.yml 配置 spring.thymeleaf 前缀 classpath:/prompts/、后缀 .tpl
- 9 个 AI 服务移除硬编码提示词与拼接逻辑,统一改用 PromptTemplateService
- 两处 buildUserPrompt 补充隐私白名单注释,禁止加入 amount/rawData/externalNo
- 新增 PromptTemplateServiceTest 与 AiPromptPayloadPrivacyTest
This commit is contained in:
2026-09-21 11:17:42 +08:00
parent 1f520f4376
commit 96130d269e
26 changed files with 566 additions and 132 deletions
@@ -0,0 +1,202 @@
package cn.rosercode.finance.prompt;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import cn.rosercode.finance.common.BusinessException;
/**
* AI 提示词模板渲染组件。
*
* <p>统一从 {@code classpath:/prompts/} 加载 Thymeleaf 文本模板(TEXT 模式)。
* 每个模板对应一个语义化方法,调用方只需传入该模板所需数据、无需关心模板名,
* 使提示词与 Java 代码解耦、便于集中维护。模板解析规则见
* {@code application.yml} 的 {@code spring.thymeleaf} 配置。</p>
*
* @date 2026-09-21 10:58:05
*/
@Component
public class PromptTemplateService {
/** Thymeleaf 模板引擎,模板前缀、后缀与模式由配置决定 */
private final TemplateEngine templateEngine;
/**
* 构造方法,注入模板引擎。
*
* @param templateEngine Thymeleaf 模板引擎
*/
public PromptTemplateService(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
/**
* 渲染自然语言记账的系统提示词(模板 {@code ai-record-system})。
*
* @param typeDesc 收支类型说明
* @param channelDesc 交易渠道说明
* @param categoryNames 可用分类名称
* @param tagNames 可用标签名称
* @param now 当前时间(yyyy-MM-dd HH:mm:ss)
* @param exampleTime 示例交易时间(yyyy-MM-dd'T'HH:mm:ss)
* @return 系统提示词
*/
public String recordSystemPrompt(String typeDesc, String channelDesc, String categoryNames,
String tagNames, String now, String exampleTime) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("typeDesc", typeDesc);
variables.put("channelDesc", channelDesc);
variables.put("categoryNames", categoryNames);
variables.put("tagNames", tagNames);
variables.put("now", now);
variables.put("exampleTime", exampleTime);
return render("ai-record-system", variables);
}
/**
* 渲染自然语言查询的系统提示词(模板 {@code ai-query-system})。
*
* @param typeDesc 收支类型说明
* @param channelDesc 交易渠道说明
* @param categoryNames 可用分类名称
* @param tagNames 可用标签名称
* @param today 当天日期(yyyy-MM-dd)
* @return 系统提示词
*/
public String querySystemPrompt(String typeDesc, String channelDesc, String categoryNames,
String tagNames, String today) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("typeDesc", typeDesc);
variables.put("channelDesc", channelDesc);
variables.put("categoryNames", categoryNames);
variables.put("tagNames", tagNames);
variables.put("today", today);
return render("ai-query-system", variables);
}
/**
* 渲染自然语言统计查询的系统提示词(模板 {@code ai-statistics-system})。
*
* @param typeDesc 收支类型说明
* @param channelDesc 交易渠道说明
* @param categoryNames 可用分类名称
* @param tagNames 可用标签名称
* @param today 当天日期(yyyy-MM-dd)
* @return 系统提示词
*/
public String statisticsSystemPrompt(String typeDesc, String channelDesc, String categoryNames,
String tagNames, String today) {
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("typeDesc", typeDesc);
variables.put("channelDesc", channelDesc);
variables.put("categoryNames", categoryNames);
variables.put("tagNames", tagNames);
variables.put("today", today);
return render("ai-statistics-system", variables);
}
/**
* 渲染生成分类的系统提示词(模板 {@code ai-category-system})。
*
* @param typeDesc 收支类型说明
* @return 系统提示词
*/
public String categorySystemPrompt(String typeDesc) {
return render("ai-category-system", Map.of("typeDesc", typeDesc));
}
/**
* 渲染生成标签的系统提示词(模板 {@code ai-tag-system})。
*
* @return 系统提示词
*/
public String tagSystemPrompt() {
return render("ai-tag-system", null);
}
/**
* 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。
*
* @return 系统提示词
*/
public String decorationEmojiSystemPrompt() {
return render("ai-decoration-emoji-system", null);
}
/**
* 渲染配色建议的系统提示词(模板 {@code ai-decoration-color-system})。
*
* @return 系统提示词
*/
public String decorationColorSystemPrompt() {
return render("ai-decoration-color-system", null);
}
/**
* 渲染严格模式分类标签建议的系统提示词(模板 {@code ai-suggestion-strict-system})。
*
* @return 系统提示词
*/
public String suggestionStrictSystemPrompt() {
return render("ai-suggestion-strict-system", null);
}
/**
* 渲染增强模式分类标签建议的系统提示词(模板 {@code ai-suggestion-new-system})。
*
* @param scope 建议范围(CATEGORY / TAG / BOTH),决定追加的限定说明
* @return 系统提示词
*/
public String suggestionNewSystemPrompt(String scope) {
return render("ai-suggestion-new-system", Map.of("scope", scope));
}
/**
* 渲染规则生成的系统提示词(模板 {@code ai-rule-system})。
*
* @return 系统提示词
*/
public String ruleSystemPrompt() {
return render("ai-rule-system", null);
}
/**
* 渲染连接测试的系统提示词(模板 {@code ai-config-test-system})。
*
* @return 系统提示词
*/
public String configTestSystemPrompt() {
return render("ai-config-test-system", null);
}
/**
* 渲染连接测试的用户提示词(模板 {@code ai-config-test-user})。
*
* @return 用户提示词
*/
public String configTestUserPrompt() {
return render("ai-config-test-user", null);
}
/**
* 渲染提示词模板,模板名与变量由各专用方法提供。
*
* @param template 模板名(不含 {@code .tpl} 后缀)
* @param variables 模板变量,允许为 null
* @return 渲染后的提示词文本
*/
private String render(String template, Map<String, Object> variables) {
Context context = new Context(Locale.CHINA, variables == null ? Collections.emptyMap() : variables);
try {
return templateEngine.process(template, context);
} catch (RuntimeException e) {
throw new BusinessException("加载提示词模板失败: " + template);
}
}
}
@@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiCategoryService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
@@ -35,26 +36,22 @@ public class AiCategoryServiceImpl implements AiCategoryService {
/** 合法的收支类型,统一取自类型枚举 */
private static final List<String> VALID_TYPES = TransactionType.codes();
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 " + TransactionType.describe() + ";"
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiCategoryServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -73,7 +70,8 @@ public class AiCategoryServiceImpl implements AiCategoryService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, text.trim());
promptTemplateService.categorySystemPrompt(TransactionType.describe()),
text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
@@ -12,6 +12,7 @@ import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.AiConfigMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiConfigVO;
@@ -34,12 +35,14 @@ public class AiConfigServiceImpl implements AiConfigService {
private final AiConfigMapper aiConfigMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final PromptTemplateService promptTemplateService;
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
AiUsageService aiUsageService) {
AiUsageService aiUsageService, PromptTemplateService promptTemplateService) {
this.aiConfigMapper = aiConfigMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -119,8 +122,8 @@ public class AiConfigServiceImpl implements AiConfigService {
DeepSeekResponse response;
try {
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
"你是一个测试助手,请只输出 JSON。",
"请返回 {\"ok\":true}");
promptTemplateService.configTestSystemPrompt(),
promptTemplateService.configTestUserPrompt());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0,
@@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
import cn.rosercode.finance.service.AiUsageService;
@@ -25,16 +26,6 @@ import cn.rosercode.finance.service.AiUsageService;
@Service
public class AiDecorationServiceImpl implements AiDecorationService {
/** Emoji 建议提示词 */
private static final String EMOJI_SYSTEM_PROMPT = "你是一个个人记账应用的图标助手。"
+ "请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。"
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"emoji\":\"🍜\"}";
/** 颜色建议提示词 */
private static final String COLOR_SYSTEM_PROMPT = "你是一个个人记账应用的配色助手。"
+ "请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。"
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"color\":\"#18a058\"}";
/** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */
private static final int EMOJI_MAX_LENGTH = 16;
@@ -45,15 +36,18 @@ public class AiDecorationServiceImpl implements AiDecorationService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiDecorationServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -66,7 +60,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type);
JsonNode root = chatAndParse(userId, config, EMOJI_SYSTEM_PROMPT, userPrompt);
JsonNode root = chatAndParse(userId, config,
promptTemplateService.decorationEmojiSystemPrompt(), userPrompt);
String emoji = root.path("emoji").asText("").trim();
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
@@ -84,7 +79,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "标签名称:" + trimmed;
JsonNode root = chatAndParse(userId, config, COLOR_SYSTEM_PROMPT, userPrompt);
JsonNode root = chatAndParse(userId, config,
promptTemplateService.decorationColorSystemPrompt(), userPrompt);
String color = root.path("color").asText("").trim();
if (!COLOR_PATTERN.matcher(color).matches()) {
throw new BusinessException("AI 未返回有效的颜色,请重试");
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiUsageService;
@@ -56,19 +57,22 @@ public class AiQueryServiceImpl implements AiQueryService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiQueryServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -122,19 +126,12 @@ public class AiQueryServiceImpl implements AiQueryService {
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String today = LocalDate.now().format(DATE_FMT);
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
+ "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
+ "今天是 " + today + "。\n"
+ "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
+ "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
+ "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}";
return promptTemplateService.querySystemPrompt(
TransactionType.describe(),
ChannelType.describe(),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
LocalDate.now().format(DATE_FMT));
}
/**
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiUsageService;
@@ -53,19 +54,22 @@ public class AiRecordServiceImpl implements AiRecordService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiRecordServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -119,22 +123,14 @@ public class AiRecordServiceImpl implements AiRecordService {
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
+ "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;"
+ "4. tagNames 从可用标签中选择(数组,可为空);5. channel 从可用交易渠道中选择,无法判断为 null;"
+ "6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;"
+ "7. includeInStats 默认 true;8. remark 给出简洁备注。\n"
+ "当前时间:" + now + "。\n"
+ "输出格式:{\"summary\":\"简要说明识别到的记账内容\",\"type\":\"EXPENSE\",\"amount\":35.5,"
+ "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"channel\":\"ALIPAY\","
+ "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\","
+ "\"includeInStats\":true,\"unmatched\":[]}";
LocalDateTime current = LocalDateTime.now();
return promptTemplateService.recordSystemPrompt(
TransactionType.describe(),
ChannelType.describe(),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
current.format(DATETIME_FMT));
}
/**
@@ -38,6 +38,7 @@ import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRuleService;
import cn.rosercode.finance.service.AiUsageService;
@@ -80,24 +81,6 @@ public class AiRuleServiceImpl implements AiRuleService {
/** 合法的十六进制颜色 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的规则生成助手。"
+ "请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。\n"
+ "每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。\n"
+ "规则字段说明:\n"
+ "- name:规则名称,简洁中文,不超过 20 字;\n"
+ "- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;\n"
+ "- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;\n"
+ "- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;\n"
+ "- newCategory:现有分类都不合适时给出 {\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🚌\"},否则为 null;\n"
+ "- tagIds:从「可用标签」中选择,数组;\n"
+ "- newTags:需要新建的标签,格式 [{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],可为空数组;\n"
+ "- reason:简要中文理由。\n"
+ "优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。\n"
+ "输出格式:{\"summary\":\"简要中文摘要\",\"rules\":[{\"name\":\"...\",\"type\":\"EXPENSE\","
+ "\"keyword\":\"星巴克\",\"categoryId\":12,\"newCategory\":null,\"tagIds\":[3],"
+ "\"newTags\":[],\"reason\":\"...\"}]}";
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
@@ -107,6 +90,7 @@ public class AiRuleServiceImpl implements AiRuleService {
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final RuleService ruleService;
private final PromptTemplateService promptTemplateService;
public AiRuleServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
@@ -116,7 +100,8 @@ public class AiRuleServiceImpl implements AiRuleService {
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
RuleService ruleService) {
RuleService ruleService,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
@@ -126,6 +111,7 @@ public class AiRuleServiceImpl implements AiRuleService {
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.ruleService = ruleService;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -169,7 +155,7 @@ public class AiRuleServiceImpl implements AiRuleService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, userPrompt);
promptTemplateService.ruleSystemPrompt(), userPrompt);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(),
System.currentTimeMillis() - start, e.getMessage());
@@ -456,6 +442,9 @@ public class AiRuleServiceImpl implements AiRuleService {
/**
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
*
* <p>隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、备注、交易渠道),
* 禁止加入 amount、rawData、externalNo 等敏感字段。</p>
*
* @param categories 用户分类列表
* @param tags 用户标签列表
* @param transactions 选中的记录列表
@@ -492,6 +481,7 @@ public class AiRuleServiceImpl implements AiRuleService {
if (!seen.add(key)) {
continue;
}
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
Map<String, Object> node = new LinkedHashMap<>();
node.put("type", transaction.getType());
node.put("remark", transaction.getRemark());
@@ -28,6 +28,7 @@ import cn.rosercode.finance.enums.StatisticsScope;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiStatisticsService;
import cn.rosercode.finance.service.AiUsageService;
@@ -68,19 +69,22 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -134,21 +138,12 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String today = LocalDate.now().format(DATE_FMT);
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
+ "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
+ "dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。\n"
+ "statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。\n"
+ "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
+ "今天是 " + today + "。\n"
+ "输出格式:{\"summary\":\"简要说明识别到的统计意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
+ "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
+ "\"maxAmount\":null,\"remark\":null,\"statsScope\":null,\"dimension\":null,\"unmatched\":[]}";
return promptTemplateService.statisticsSystemPrompt(
TransactionType.describe(),
ChannelType.describe(),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
LocalDate.now().format(DATE_FMT));
}
/**
@@ -36,6 +36,7 @@ import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
@@ -68,27 +69,6 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
/** 严格模式系统提示词,仅允许使用现有分类与标签,供编辑弹窗单条建议使用 */
private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
+ "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
+ "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
+ "4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
/** 增强模式系统提示词,允许在现有候选都不合适时提出新建分类与标签 */
private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
+ "为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。"
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
+ "2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;"
+ "3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),"
+ "或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);"
+ "4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;"
+ "5. 标签可多选,也可为空数组;6. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID或null,"
+ "\"newCategory\":{\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🐾\"}或null,"
+ "\"tagIds\":[标签ID],\"newTags\":[{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],\"reason\":\"简要中文理由\"}]}";
private final AiConfigService aiConfigService;
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
@@ -97,6 +77,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
private final DeepSeekClient deepSeekClient;
private final ObjectMapper objectMapper;
private final AiUsageService aiUsageService;
private final PromptTemplateService promptTemplateService;
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
TransactionMapper transactionMapper,
@@ -105,7 +86,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
TransactionTagMapper transactionTagMapper,
DeepSeekClient deepSeekClient,
ObjectMapper objectMapper,
AiUsageService aiUsageService) {
AiUsageService aiUsageService,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
@@ -114,6 +96,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
this.deepSeekClient = deepSeekClient;
this.objectMapper = objectMapper;
this.aiUsageService = aiUsageService;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -242,7 +225,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
STRICT_SYSTEM_PROMPT, userPrompt);
promptTemplateService.suggestionStrictSystemPrompt(), userPrompt);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
System.currentTimeMillis() - start, e.getMessage());
@@ -348,13 +331,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
* @return 系统提示词
*/
private String buildSystemPrompt(String scope) {
if ("CATEGORY".equals(scope)) {
return NEW_SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。";
}
if ("TAG".equals(scope)) {
return NEW_SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。";
}
return NEW_SYSTEM_PROMPT;
return promptTemplateService.suggestionNewSystemPrompt(scope);
}
/**
@@ -573,6 +550,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/**
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
*
* <p>隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、交易时间、渠道、备注),
* 禁止加入 amount、rawData、externalNo 等敏感字段。</p>
*
* @param categories 用户分类列表
* @param tags 用户标签列表
* @param transactions 待分析记录列表
@@ -593,6 +573,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
node.put("name", tag.getName());
return node;
}).collect(Collectors.toList()));
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
payload.put("transactions", transactions.stream().map(transaction -> {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", transaction.getId());
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiTagService;
import cn.rosercode.finance.service.AiUsageService;
@@ -34,27 +35,22 @@ public class AiTagServiceImpl implements AiTagService {
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的标签助手。"
+ "请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。"
+ "要求:1. name 为 2-6 个汉字的标签名称;"
+ "2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;"
+ "3. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"name\":\"报销\",\"color\":\"#2f80ed\",\"reason\":\"简要中文理由\"}";
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiTagServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
@@ -73,7 +69,7 @@ public class AiTagServiceImpl implements AiTagService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, text.trim());
promptTemplateService.tagSystemPrompt(), text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
+7
View File
@@ -5,6 +5,13 @@ spring:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# AI 提示词模板:TEXT 模式加载 classpath:/prompts/*.tpl,
# 由 cn.rosercode.finance.prompt.PromptTemplateService 统一渲染
thymeleaf:
prefix: classpath:/prompts/
suffix: .tpl
mode: TEXT
encoding: UTF-8
# 数据源不再在此固定配置,改由运行时数据库配置(config/database.json)决定,
# 详见 cn.rosercode.finance.config.datasource 包。
@@ -0,0 +1,5 @@
你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求:
1. name 为 2-6 个汉字的分类名称;
2. type 只能是 [(${typeDesc})];
3. icon 为与该分类最贴切的一个 Emoji;
4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"}
@@ -0,0 +1 @@
你是一个测试助手,请只输出 JSON。
@@ -0,0 +1 @@
请返回 {"ok":true}
@@ -0,0 +1,2 @@
你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。
只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"}
@@ -0,0 +1,2 @@
你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。
只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"}
@@ -0,0 +1,11 @@
你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。
可用分类:[(${categoryNames})]。
可用标签:[(${tagNames})]。
时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;
若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
未提及的字段请留空(数组用 [],其余用 null)。
分类和标签只能使用上面给出的名称。
今天是 [(${today})]。
输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]}
@@ -0,0 +1,16 @@
你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。
可用分类:[(${categoryNames})]。
可用标签:[(${tagNames})]。
规则:
1. type 默认为 EXPENSE,除非明显是收入;
2. amount 为必填数字;
3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;
4. tagNames 从可用标签中选择(数组,可为空);
5. channel 从可用交易渠道中选择,无法判断为 null;
6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;
7. includeInStats 默认 true;
8. remark 给出简洁备注。
当前时间:[(${now})]。
输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]}
@@ -0,0 +1,13 @@
你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。
每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。
规则字段说明:
- name:规则名称,简洁中文,不超过 20 字;
- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;
- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;
- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;
- newCategory:现有分类都不合适时给出 {"name":"分类名","type":"EXPENSE","icon":"🚌"},否则为 null;
- tagIds:从「可用标签」中选择,数组;
- newTags:需要新建的标签,格式 [{"name":"标签名","color":"#2f80ed"}],可为空数组;
- reason:简要中文理由。
优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。
输出格式:{"summary":"简要中文摘要","rules":[{"name":"...","type":"EXPENSE","keyword":"星巴克","categoryId":12,"newCategory":null,"tagIds":[3],"newTags":[],"reason":"..."}]}
@@ -0,0 +1,13 @@
你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。
可用的收支类型:[(${typeDesc})]。
可用的交易渠道:[(${channelDesc})]。
可用分类:[(${categoryNames})]。
可用标签:[(${tagNames})]。
时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;
若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。
statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。
未提及的字段请留空(数组用 [],其余用 null)。
分类和标签只能使用上面给出的名称。
今天是 [(${today})]。
输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]}
@@ -0,0 +1,9 @@
你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。要求:
1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);
2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;
3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);
4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;
5. 标签可多选,也可为空数组;
6. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID或null,"newCategory":{"name":"分类名","type":"EXPENSE","icon":"🐾"}或null,"tagIds":[标签ID],"newTags":[{"name":"标签名","color":"#2f80ed"}],"reason":"简要中文理由"}]}
[# th:if="${scope == 'CATEGORY'}"]注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。[/]
[# th:if="${scope == 'TAG'}"]注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。[/]
@@ -0,0 +1,5 @@
你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。要求:
1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);
2. 只能使用给定列表中的 ID,不要编造;
3. 标签可多选,也可为空数组;
4. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"reason":"简要中文理由"}]}
@@ -0,0 +1,4 @@
你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求:
1. name 为 2-6 个汉字的标签名称;
2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;
3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"}