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:
@@ -28,6 +28,8 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
// Thymeleaf(TEXT 模式):加载 classpath:/prompts 下的 AI 提示词模板
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
implementation 'org.springframework.security:spring-security-crypto'
|
||||
|
||||
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":"简要中文理由"}
|
||||
@@ -0,0 +1,65 @@
|
||||
package cn.rosercode.finance.prompt;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
|
||||
/**
|
||||
* 提示词模板渲染测试,验证各模板可正常加载、变量替换与 scope 条件分支。
|
||||
*
|
||||
* @date 2026-09-21 11:01:25
|
||||
*/
|
||||
class PromptTemplateServiceTest {
|
||||
|
||||
private PromptTemplateService promptTemplateService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setPrefix("prompts/");
|
||||
resolver.setSuffix(".tpl");
|
||||
resolver.setTemplateMode(TemplateMode.TEXT);
|
||||
resolver.setCharacterEncoding("UTF-8");
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
promptTemplateService = new PromptTemplateService(engine);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderRecordSystemPrompt() {
|
||||
String result = promptTemplateService.recordSystemPrompt(
|
||||
"INCOME(收入)、EXPENSE(支出)",
|
||||
"ALIPAY(支付宝)",
|
||||
"餐饮",
|
||||
"无",
|
||||
"2026-09-21 11:00:00",
|
||||
"2026-09-21T11:00:00");
|
||||
assertThat(result).contains("可用的收支类型:INCOME(收入)、EXPENSE(支出)。");
|
||||
assertThat(result).contains("可用分类:餐饮。");
|
||||
assertThat(result).contains("可用标签:无。");
|
||||
assertThat(result).contains("当前时间:2026-09-21 11:00:00。");
|
||||
assertThat(result).contains("\"transactionTime\":\"2026-09-21T11:00:00\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSuggestionScopeSuffix() {
|
||||
String both = promptTemplateService.suggestionNewSystemPrompt("BOTH");
|
||||
assertThat(both).doesNotContain("注意:本次只需给出");
|
||||
String category = promptTemplateService.suggestionNewSystemPrompt("CATEGORY");
|
||||
assertThat(category).contains("注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。");
|
||||
String tag = promptTemplateService.suggestionNewSystemPrompt("TAG");
|
||||
assertThat(tag).contains("注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderStaticPrompts() {
|
||||
assertThat(promptTemplateService.tagSystemPrompt()).contains("你是一个个人记账应用的标签助手。");
|
||||
assertThat(promptTemplateService.ruleSystemPrompt()).contains("你是一个个人记账应用的规则生成助手。");
|
||||
assertThat(promptTemplateService.configTestSystemPrompt()).contains("你是一个测试助手,请只输出 JSON。");
|
||||
assertThat(promptTemplateService.configTestUserPrompt()).contains("请返回 {\"ok\":true}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
|
||||
/**
|
||||
* AI 用户提示词隐私测试。
|
||||
*
|
||||
* <p>校验 {@code buildUserPrompt} 生成的 JSON 载荷仅含白名单字段,
|
||||
* 不含 amount、rawData、externalNo 等敏感信息,防止后续改动误加字段导致数据外发。</p>
|
||||
*
|
||||
* @date 2026-09-21 11:10:58
|
||||
*/
|
||||
class AiPromptPayloadPrivacyTest {
|
||||
|
||||
/** 禁止出现在提示词载荷中的敏感字段名 */
|
||||
private static final List<String> SENSITIVE_FIELDS = List.of("amount", "rawData", "externalNo");
|
||||
|
||||
/**
|
||||
* AI 建议的批量用户提示词不得包含敏感字段。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void suggestionUserPromptExcludesSensitiveFields() throws Exception {
|
||||
AiSuggestionServiceImpl service = new AiSuggestionServiceImpl(
|
||||
null, null, null, null, null, null, new ObjectMapper().findAndRegisterModules(), null, null);
|
||||
Method method = AiSuggestionServiceImpl.class.getDeclaredMethod(
|
||||
"buildUserPrompt", List.class, List.class, List.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String json = (String) method.invoke(service,
|
||||
List.of(category()), List.of(tag()), List.of(transaction()));
|
||||
|
||||
assertExcludesSensitiveFields(json);
|
||||
assertThat(json).contains("remark").contains("午餐");
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 规则生成的用户提示词不得包含敏感字段。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void ruleUserPromptExcludesSensitiveFields() throws Exception {
|
||||
AiRuleServiceImpl service = new AiRuleServiceImpl(
|
||||
null, null, null, new ObjectMapper().findAndRegisterModules(), null, null, null, null, null, null);
|
||||
Method method = AiRuleServiceImpl.class.getDeclaredMethod(
|
||||
"buildUserPrompt", List.class, List.class, List.class, Map.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String json = (String) method.invoke(service, List.of(category()), List.of(tag()),
|
||||
List.of(transaction()), Map.of(1L, List.of("报销")));
|
||||
|
||||
assertExcludesSensitiveFields(json);
|
||||
assertThat(json).contains("remark").contains("午餐");
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言载荷不含任何敏感字段名。
|
||||
*
|
||||
* @param json 提示词载荷
|
||||
*/
|
||||
private void assertExcludesSensitiveFields(String json) {
|
||||
for (String field : SENSITIVE_FIELDS) {
|
||||
assertThat(json).doesNotContain(field);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用于测试的分类。
|
||||
*
|
||||
* @return 分类实体
|
||||
*/
|
||||
private Category category() {
|
||||
Category category = new Category();
|
||||
category.setId(1L);
|
||||
category.setName("餐饮");
|
||||
category.setType("EXPENSE");
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用于测试的标签。
|
||||
*
|
||||
* @return 标签实体
|
||||
*/
|
||||
private Tag tag() {
|
||||
Tag tag = new Tag();
|
||||
tag.setId(1L);
|
||||
tag.setName("报销");
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用于测试的交易记录,并刻意写入敏感字段以验证其不会外发。
|
||||
*
|
||||
* @return 交易记录实体
|
||||
*/
|
||||
private Transaction transaction() {
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setId(1L);
|
||||
transaction.setType("EXPENSE");
|
||||
transaction.setChannel("ALIPAY");
|
||||
transaction.setRemark("午餐");
|
||||
transaction.setTransactionTime(LocalDateTime.now());
|
||||
transaction.setAmount(new BigDecimal("35.50"));
|
||||
transaction.setExternalNo("2026092100001");
|
||||
transaction.setRawData("{\"secret\":\"raw\"}");
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user