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-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
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 'org.springframework.security:spring-security-crypto'
|
||||||
|
|
||||||
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
|
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.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
import cn.rosercode.finance.enums.TransactionType;
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiCategoryService;
|
import cn.rosercode.finance.service.AiCategoryService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -35,26 +36,22 @@ public class AiCategoryServiceImpl implements AiCategoryService {
|
|||||||
/** 合法的收支类型,统一取自类型枚举 */
|
/** 合法的收支类型,统一取自类型枚举 */
|
||||||
private static final List<String> VALID_TYPES = TransactionType.codes();
|
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 AiConfigService aiConfigService;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiCategoryServiceImpl(AiConfigService aiConfigService,
|
public AiCategoryServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +70,8 @@ public class AiCategoryServiceImpl implements AiCategoryService {
|
|||||||
DeepSeekResponse aiResponse;
|
DeepSeekResponse aiResponse;
|
||||||
try {
|
try {
|
||||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||||
SYSTEM_PROMPT, text.trim());
|
promptTemplateService.categorySystemPrompt(TransactionType.describe()),
|
||||||
|
text.trim());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
// 失败也记录一次使用,便于排查问题
|
// 失败也记录一次使用,便于排查问题
|
||||||
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
|
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.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
import cn.rosercode.finance.mapper.AiConfigMapper;
|
import cn.rosercode.finance.mapper.AiConfigMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
import cn.rosercode.finance.vo.AiConfigVO;
|
import cn.rosercode.finance.vo.AiConfigVO;
|
||||||
@@ -34,12 +35,14 @@ public class AiConfigServiceImpl implements AiConfigService {
|
|||||||
private final AiConfigMapper aiConfigMapper;
|
private final AiConfigMapper aiConfigMapper;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
|
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService) {
|
AiUsageService aiUsageService, PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigMapper = aiConfigMapper;
|
this.aiConfigMapper = aiConfigMapper;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,8 +122,8 @@ public class AiConfigServiceImpl implements AiConfigService {
|
|||||||
DeepSeekResponse response;
|
DeepSeekResponse response;
|
||||||
try {
|
try {
|
||||||
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||||
"你是一个测试助手,请只输出 JSON。",
|
promptTemplateService.configTestSystemPrompt(),
|
||||||
"请返回 {\"ok\":true}");
|
promptTemplateService.configTestUserPrompt());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
// 失败也记录一次使用,便于排查问题
|
// 失败也记录一次使用,便于排查问题
|
||||||
aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0,
|
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.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
import cn.rosercode.finance.enums.TransactionType;
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiDecorationService;
|
import cn.rosercode.finance.service.AiDecorationService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -25,16 +26,6 @@ import cn.rosercode.finance.service.AiUsageService;
|
|||||||
@Service
|
@Service
|
||||||
public class AiDecorationServiceImpl implements AiDecorationService {
|
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) */
|
/** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */
|
||||||
private static final int EMOJI_MAX_LENGTH = 16;
|
private static final int EMOJI_MAX_LENGTH = 16;
|
||||||
|
|
||||||
@@ -45,15 +36,18 @@ public class AiDecorationServiceImpl implements AiDecorationService {
|
|||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiDecorationServiceImpl(AiConfigService aiConfigService,
|
public AiDecorationServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +60,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
|
|||||||
String trimmed = requireName(name);
|
String trimmed = requireName(name);
|
||||||
AiConfig config = requireConfig(userId);
|
AiConfig config = requireConfig(userId);
|
||||||
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type);
|
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();
|
String emoji = root.path("emoji").asText("").trim();
|
||||||
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
|
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
|
||||||
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
|
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
|
||||||
@@ -84,7 +79,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
|
|||||||
String trimmed = requireName(name);
|
String trimmed = requireName(name);
|
||||||
AiConfig config = requireConfig(userId);
|
AiConfig config = requireConfig(userId);
|
||||||
String userPrompt = "标签名称:" + trimmed;
|
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();
|
String color = root.path("color").asText("").trim();
|
||||||
if (!COLOR_PATTERN.matcher(color).matches()) {
|
if (!COLOR_PATTERN.matcher(color).matches()) {
|
||||||
throw new BusinessException("AI 未返回有效的颜色,请重试");
|
throw new BusinessException("AI 未返回有效的颜色,请重试");
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
|
|||||||
import cn.rosercode.finance.enums.TransactionType;
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiQueryService;
|
import cn.rosercode.finance.service.AiQueryService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -56,19 +57,22 @@ public class AiQueryServiceImpl implements AiQueryService {
|
|||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiQueryServiceImpl(AiConfigService aiConfigService,
|
public AiQueryServiceImpl(AiConfigService aiConfigService,
|
||||||
CategoryMapper categoryMapper,
|
CategoryMapper categoryMapper,
|
||||||
TagMapper tagMapper,
|
TagMapper tagMapper,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.categoryMapper = categoryMapper;
|
this.categoryMapper = categoryMapper;
|
||||||
this.tagMapper = tagMapper;
|
this.tagMapper = tagMapper;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,19 +126,12 @@ public class AiQueryServiceImpl implements AiQueryService {
|
|||||||
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
||||||
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
||||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
return promptTemplateService.querySystemPrompt(
|
||||||
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
|
TransactionType.describe(),
|
||||||
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
ChannelType.describe(),
|
||||||
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
categoryNames.isEmpty() ? "无" : categoryNames,
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
tagNames.isEmpty() ? "无" : tagNames,
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
LocalDate.now().format(DATE_FMT));
|
||||||
+ "时间范围请使用 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\":[]}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
|
|||||||
import cn.rosercode.finance.enums.TransactionType;
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiRecordService;
|
import cn.rosercode.finance.service.AiRecordService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -53,19 +54,22 @@ public class AiRecordServiceImpl implements AiRecordService {
|
|||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiRecordServiceImpl(AiConfigService aiConfigService,
|
public AiRecordServiceImpl(AiConfigService aiConfigService,
|
||||||
CategoryMapper categoryMapper,
|
CategoryMapper categoryMapper,
|
||||||
TagMapper tagMapper,
|
TagMapper tagMapper,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.categoryMapper = categoryMapper;
|
this.categoryMapper = categoryMapper;
|
||||||
this.tagMapper = tagMapper;
|
this.tagMapper = tagMapper;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,22 +123,14 @@ public class AiRecordServiceImpl implements AiRecordService {
|
|||||||
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
||||||
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
||||||
String tagNames = tags.stream().map(Tag::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"));
|
LocalDateTime current = LocalDateTime.now();
|
||||||
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
|
return promptTemplateService.recordSystemPrompt(
|
||||||
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
TransactionType.describe(),
|
||||||
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
ChannelType.describe(),
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
categoryNames.isEmpty() ? "无" : categoryNames,
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
tagNames.isEmpty() ? "无" : tagNames,
|
||||||
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
|
current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
|
||||||
+ "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;"
|
current.format(DATETIME_FMT));
|
||||||
+ "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\":[]}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import cn.rosercode.finance.mapper.CategoryMapper;
|
|||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiRuleService;
|
import cn.rosercode.finance.service.AiRuleService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
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}$");
|
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 AiConfigService aiConfigService;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
@@ -107,6 +90,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
private final TransactionTagMapper transactionTagMapper;
|
private final TransactionTagMapper transactionTagMapper;
|
||||||
private final RuleService ruleService;
|
private final RuleService ruleService;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
public AiRuleServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
@@ -116,7 +100,8 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
CategoryMapper categoryMapper,
|
CategoryMapper categoryMapper,
|
||||||
TagMapper tagMapper,
|
TagMapper tagMapper,
|
||||||
TransactionTagMapper transactionTagMapper,
|
TransactionTagMapper transactionTagMapper,
|
||||||
RuleService ruleService) {
|
RuleService ruleService,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
@@ -126,6 +111,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
this.tagMapper = tagMapper;
|
this.tagMapper = tagMapper;
|
||||||
this.transactionTagMapper = transactionTagMapper;
|
this.transactionTagMapper = transactionTagMapper;
|
||||||
this.ruleService = ruleService;
|
this.ruleService = ruleService;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +155,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
DeepSeekResponse aiResponse;
|
DeepSeekResponse aiResponse;
|
||||||
try {
|
try {
|
||||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||||
SYSTEM_PROMPT, userPrompt);
|
promptTemplateService.ruleSystemPrompt(), userPrompt);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(),
|
aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(),
|
||||||
System.currentTimeMillis() - start, e.getMessage());
|
System.currentTimeMillis() - start, e.getMessage());
|
||||||
@@ -456,6 +442,9 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
/**
|
/**
|
||||||
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
|
||||||
*
|
*
|
||||||
|
* <p>隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、备注、交易渠道),
|
||||||
|
* 禁止加入 amount、rawData、externalNo 等敏感字段。</p>
|
||||||
|
*
|
||||||
* @param categories 用户分类列表
|
* @param categories 用户分类列表
|
||||||
* @param tags 用户标签列表
|
* @param tags 用户标签列表
|
||||||
* @param transactions 选中的记录列表
|
* @param transactions 选中的记录列表
|
||||||
@@ -492,6 +481,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
if (!seen.add(key)) {
|
if (!seen.add(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
|
||||||
Map<String, Object> node = new LinkedHashMap<>();
|
Map<String, Object> node = new LinkedHashMap<>();
|
||||||
node.put("type", transaction.getType());
|
node.put("type", transaction.getType());
|
||||||
node.put("remark", transaction.getRemark());
|
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.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiStatisticsService;
|
import cn.rosercode.finance.service.AiStatisticsService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -68,19 +69,22 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
|||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
|
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
|
||||||
CategoryMapper categoryMapper,
|
CategoryMapper categoryMapper,
|
||||||
TagMapper tagMapper,
|
TagMapper tagMapper,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.categoryMapper = categoryMapper;
|
this.categoryMapper = categoryMapper;
|
||||||
this.tagMapper = tagMapper;
|
this.tagMapper = tagMapper;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,21 +138,12 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
|||||||
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
private String buildSystemPrompt(List<Category> categories, List<Tag> tags) {
|
||||||
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
|
||||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
return promptTemplateService.statisticsSystemPrompt(
|
||||||
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
|
TransactionType.describe(),
|
||||||
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
ChannelType.describe(),
|
||||||
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
categoryNames.isEmpty() ? "无" : categoryNames,
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
tagNames.isEmpty() ? "无" : tagNames,
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
LocalDate.now().format(DATE_FMT));
|
||||||
+ "时间范围请使用 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\":[]}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import cn.rosercode.finance.mapper.CategoryMapper;
|
|||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiSuggestionService;
|
import cn.rosercode.finance.service.AiSuggestionService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
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 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 AiConfigService aiConfigService;
|
||||||
private final TransactionMapper transactionMapper;
|
private final TransactionMapper transactionMapper;
|
||||||
private final CategoryMapper categoryMapper;
|
private final CategoryMapper categoryMapper;
|
||||||
@@ -97,6 +77,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
|
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
|
||||||
TransactionMapper transactionMapper,
|
TransactionMapper transactionMapper,
|
||||||
@@ -105,7 +86,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
TransactionTagMapper transactionTagMapper,
|
TransactionTagMapper transactionTagMapper,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
AiUsageService aiUsageService) {
|
AiUsageService aiUsageService,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.transactionMapper = transactionMapper;
|
this.transactionMapper = transactionMapper;
|
||||||
this.categoryMapper = categoryMapper;
|
this.categoryMapper = categoryMapper;
|
||||||
@@ -114,6 +96,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +225,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
DeepSeekResponse aiResponse;
|
DeepSeekResponse aiResponse;
|
||||||
try {
|
try {
|
||||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||||
STRICT_SYSTEM_PROMPT, userPrompt);
|
promptTemplateService.suggestionStrictSystemPrompt(), userPrompt);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
|
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
|
||||||
System.currentTimeMillis() - start, e.getMessage());
|
System.currentTimeMillis() - start, e.getMessage());
|
||||||
@@ -348,13 +331,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
* @return 系统提示词
|
* @return 系统提示词
|
||||||
*/
|
*/
|
||||||
private String buildSystemPrompt(String scope) {
|
private String buildSystemPrompt(String scope) {
|
||||||
if ("CATEGORY".equals(scope)) {
|
return promptTemplateService.suggestionNewSystemPrompt(scope);
|
||||||
return NEW_SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。";
|
|
||||||
}
|
|
||||||
if ("TAG".equals(scope)) {
|
|
||||||
return NEW_SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。";
|
|
||||||
}
|
|
||||||
return NEW_SYSTEM_PROMPT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -573,6 +550,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
/**
|
/**
|
||||||
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
|
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
|
||||||
*
|
*
|
||||||
|
* <p>隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、交易时间、渠道、备注),
|
||||||
|
* 禁止加入 amount、rawData、externalNo 等敏感字段。</p>
|
||||||
|
*
|
||||||
* @param categories 用户分类列表
|
* @param categories 用户分类列表
|
||||||
* @param tags 用户标签列表
|
* @param tags 用户标签列表
|
||||||
* @param transactions 待分析记录列表
|
* @param transactions 待分析记录列表
|
||||||
@@ -593,6 +573,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
node.put("name", tag.getName());
|
node.put("name", tag.getName());
|
||||||
return node;
|
return node;
|
||||||
}).collect(Collectors.toList()));
|
}).collect(Collectors.toList()));
|
||||||
|
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
|
||||||
payload.put("transactions", transactions.stream().map(transaction -> {
|
payload.put("transactions", 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());
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
|
|||||||
import cn.rosercode.finance.common.BusinessException;
|
import cn.rosercode.finance.common.BusinessException;
|
||||||
import cn.rosercode.finance.entity.AiConfig;
|
import cn.rosercode.finance.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiTagService;
|
import cn.rosercode.finance.service.AiTagService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
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}$");
|
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 AiConfigService aiConfigService;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
public AiTagServiceImpl(AiConfigService aiConfigService,
|
public AiTagServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
PromptTemplateService promptTemplateService) {
|
||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.promptTemplateService = promptTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,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(),
|
||||||
SYSTEM_PROMPT, text.trim());
|
promptTemplateService.tagSystemPrompt(), text.trim());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
// 失败也记录一次使用,便于排查问题
|
// 失败也记录一次使用,便于排查问题
|
||||||
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
|
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ spring:
|
|||||||
multipart:
|
multipart:
|
||||||
max-file-size: 10MB
|
max-file-size: 10MB
|
||||||
max-request-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)决定,
|
# 数据源不再在此固定配置,改由运行时数据库配置(config/database.json)决定,
|
||||||
# 详见 cn.rosercode.finance.config.datasource 包。
|
# 详见 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