package com.l.tracecd.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.l.tracecd.config.DeepSeekConfig; import com.l.tracecd.constant.Constants; import com.l.tracecd.dto.DeepSeekRequest; import com.l.tracecd.dto.DeepSeekResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * DeepSeek 大模型服务 * 负责与 DeepSeek API 交互,包括 function call 处理 */ @Service public class LlmService { private static final Logger log = LoggerFactory.getLogger(LlmService.class); private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final RestClient deepSeekRestClient; private final DeepSeekConfig.DeepSeekProperties deepSeekProperties; private final ObjectMapper objectMapper; public LlmService(RestClient deepSeekRestClient, DeepSeekConfig.DeepSeekProperties deepSeekProperties, ObjectMapper objectMapper) { this.deepSeekRestClient = deepSeekRestClient; this.deepSeekProperties = deepSeekProperties; this.objectMapper = objectMapper; } /** * 分析用户语音文本,返回 LLM 响应 * 可能包含文本回复或 function call 请求 * * @param userText ASR 识别后的文本 * @return LLM 的响应消息 */ public DeepSeekResponse.ResponseMessage analyze(String userText) { long start = System.currentTimeMillis(); DeepSeekRequest request = new DeepSeekRequest(); request.setModel(deepSeekProperties.getModel()); List messages = new ArrayList<>(); messages.add(new DeepSeekRequest.Message("system", buildSystemPrompt())); messages.add(new DeepSeekRequest.Message("user", userText)); request.setMessages(messages); // 设置 function call 工具 request.setTools(buildTools()); log.info("调用 DeepSeek API,用户输入: {}", userText); DeepSeekResponse response = deepSeekRestClient.post() .body(request) .retrieve() .body(DeepSeekResponse.class); long elapsed = System.currentTimeMillis() - start; log.info("DeepSeek 响应完成,耗时: {}ms", elapsed); if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) { throw new RuntimeException("DeepSeek 返回空响应"); } return response.getChoices().getFirst().getMessage(); } /** * 将 function call 结果返回给 LLM,获取最终回复 * * @param messages 完整对话历史(包含 tool call 和 tool 结果) * @return LLM 的最终响应 */ public DeepSeekResponse.ResponseMessage continueWithToolResult( List messages) { long start = System.currentTimeMillis(); DeepSeekRequest request = new DeepSeekRequest(); request.setModel(deepSeekProperties.getModel()); request.setMessages(messages); request.setTools(buildTools()); log.debug("继续与 DeepSeek 对话,messages 数量: {}", messages.size()); DeepSeekResponse response = deepSeekRestClient.post() .body(request) .retrieve() .body(DeepSeekResponse.class); long elapsed = System.currentTimeMillis() - start; log.info("DeepSeek 二次响应完成,耗时: {}ms", elapsed); if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) { throw new RuntimeException("DeepSeek 返回空响应"); } return response.getChoices().get(0).getMessage(); } /** * 构建 System Prompt */ private String buildSystemPrompt() { String now = LocalDateTime.now().format(DT_FMT); return """ 你是一个日常记账助手。当前时间是 %s。 你的任务是分析用户的输入,判断意图并做出相应处理: ## 意图判断 1. **录入(record)**: 用户描述了在某个时间、地点、花费金额做了某事。 例如:"我花20块钱在小区门口张三面馆吃了一碗肉丝面" 2. **查询(query)**: 用户想查询已录入的事项。 例如:"我昨天中午12点做了什么"、"我今天一共花了多少钱"、"我上次吃面是什么时候" 3. **聊天(chat)**: 用户输入与记账无关。 例如:"你好"、"今天天气怎么样" ## 录入意图处理 当用户意图是录入时,分析内容并提取字段,生成标准 MySQL INSERT 语句。 - person: 人物,默认为"我" - record_time: 事项发生时间,如用户未明确时间则用当前时间,格式 yyyy-MM-dd HH:mm:ss - location: 地点 - content: 事项内容概述(一句话) - category: 分类,2-4个字,如"吃饭"、"修车"、"旅游"、"加油"、"租房"、"水电"、"话费"、"购物"、"剪头"等 - amount: 花费金额(数字) 只输出 SQL 语句,不要用```sql```包裹,不要有任何其他内容。 格式举例:INSERT INTO t_daily_record (person, record_time, location, content, category, amount) VALUES ('我', '2025-01-15 12:00:00', '小区门口张三面馆', '吃了一碗肉丝面', '吃饭', 20.00) ## 查询意图处理 当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。 - 人物、日期、内容、金额相关字段查询 - 查询完成后根据返回数据生成回复: * 多条数据用 markdown 表格展示,表格上方展示金额汇总 * 一条数据直接描述 * 无数据告知用户未找到 * 回复要友好自然 ## 聊天意图处理 当用户输入与记账无关时,直接友好回复。 ## 重要规则 - 只输出 SQL 语句或友好回复,不要输出分析过程 - 时间推算要准确:"昨天"推算为具体日期,"中午12点"设为12:00:00 - 如果用户没有提及金额,amount 设为 0 - 分类必须是2-4个中文字符 """.formatted(now); } /** * 构建 Function Call 工具定义 */ private List buildTools() { Map sqlProperty = Map.of( "type", "object", "properties", Map.of( "sql", Map.of( "type", "string", "description", "合法的 MySQL SELECT 语句,仅允许 SELECT,表名限定为 t_daily_record" ) ), "required", List.of("sql") ); DeepSeekRequest.ToolFunction func = new DeepSeekRequest.ToolFunction( Constants.FUNCTION_QUERY_RECORDS, "执行 SQL SELECT 查询获取事项数据。仅支持 SELECT 语句,表名限定为 t_daily_record。查询结果以 JSON 数组返回。", new DeepSeekRequest.Parameters( Map.of("sql", sqlProperty.get("properties")), List.of("sql") ) ); // Fix: properly set the properties type func.getParameters().setProperties(Map.of( "sql", Map.of( "type", "string", "description", "合法的 MySQL SELECT 语句,仅允许 SELECT,表名限定为 t_daily_record" ) )); return List.of(new DeepSeekRequest.Tool("function", func)); } /** * 将 LLM 的 INSERT SQL 再次发送给 LLM 要求修正 */ public DeepSeekResponse.ResponseMessage retryInsert(String userText, String errorMessage) { String prompt = """ 之前生成的 SQL 语句不合法。错误信息: %s 用户原始输入: %s 请重新生成合法的 INSERT INTO t_daily_record 语句。 只输出 SQL 语句,不要有其他内容。 """.formatted(errorMessage, userText); DeepSeekRequest request = new DeepSeekRequest(); request.setModel(deepSeekProperties.getModel()); request.setMessages(List.of( new DeepSeekRequest.Message("system", buildSystemPrompt()), new DeepSeekRequest.Message("user", prompt) )); // 重试时不带 tools log.info("重试 SQL 生成,错误: {}", errorMessage); DeepSeekResponse response = deepSeekRestClient.post() .body(request) .retrieve() .body(DeepSeekResponse.class); if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) { throw new RuntimeException("DeepSeek 返回空响应"); } return response.getChoices().get(0).getMessage(); } }