From d9117ce8dbfdecabc2d216ef2619c5db7aba6372 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:25:22 +0800 Subject: [PATCH] feat(M1): single ReAct agent + streaming chat + KG form-lookup tool --- src/main/java/com/xly/agent/ReActAgent.java | 18 ++++++++++++++++++ src/main/java/com/xly/config/AgentConfig.java | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/SystemPromptService.java | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/tool/KgQueryTool.java | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/web/AgentChatController.java | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/resources/application-saaslocal.yml | 22 ++++++++++++++++++++++ src/main/resources/templates/chat.html | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------- 7 files changed, 402 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/xly/agent/ReActAgent.java create mode 100644 src/main/java/com/xly/config/AgentConfig.java create mode 100644 src/main/java/com/xly/service/SystemPromptService.java create mode 100644 src/main/java/com/xly/tool/KgQueryTool.java create mode 100644 src/main/java/com/xly/web/AgentChatController.java create mode 100644 src/main/resources/application-saaslocal.yml diff --git a/src/main/java/com/xly/agent/ReActAgent.java b/src/main/java/com/xly/agent/ReActAgent.java new file mode 100644 index 0000000..0918971 --- /dev/null +++ b/src/main/java/com/xly/agent/ReActAgent.java @@ -0,0 +1,18 @@ +package com.xly.agent; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.TokenStream; +import dev.langchain4j.service.UserMessage; + +/** + * 单一 ReAct 智能体。 + * + *

LangChain4j 的 {@code AiServices} 原生工具调用循环即 ReAct:模型自行决定是否调用工具、 + * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 SceneSelector 路由——由 agent 自路由。 + * + *

返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 + */ +public interface ReActAgent { + + TokenStream chat(@MemoryId String conversationId, @UserMessage String userInput); +} diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java new file mode 100644 index 0000000..51479fd --- /dev/null +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -0,0 +1,53 @@ +package com.xly.config; + +import com.xly.agent.ReActAgent; +import com.xly.service.SystemPromptService; +import com.xly.tool.KgQueryTool; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.ollama.OllamaStreamingChatModel; +import dev.langchain4j.service.AiServices; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * 组装单一 ReAct agent(M1)。 + * + *

= 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆 + * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。 + */ +@Configuration +public class AgentConfig { + + @Value("${langchain4j.ollama.base-url}") + private String ollamaUrl; + + @Value("${langchain4j.ollama.chat-model-name}") + private String chatModelName; + + /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ + @Bean("agentStreamingModel") + public OllamaStreamingChatModel agentStreamingModel() { + return OllamaStreamingChatModel.builder() + .baseUrl(ollamaUrl) + .modelName(chatModelName) + .temperature(0.1) + .topP(0.9) + .numPredict(2048) + .timeout(Duration.ofSeconds(180)) + .build(); + } + + @Bean + public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool) { + String systemPrompt = systemPromptService.buildSystemPrompt(); + return AiServices.builder(ReActAgent.class) + .streamingChatModel(agentStreamingModel()) + .tools(kgQueryTool) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .systemMessageProvider(memoryId -> systemPrompt) + .build(); + } +} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java new file mode 100644 index 0000000..33091a8 --- /dev/null +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -0,0 +1,81 @@ +package com.xly.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 构建单 agent 的 system prompt。 + * + *

核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)渲染进 + * system prompt,作为常驻的「路由地图」——让单 agent 先判断问题属于哪个业务域、涉及哪些单据, + * 再决定调用哪个工具。L1 体量小且永远相关,适合常驻 prompt;L2/L3 大而稀疏,走工具按需查。 + */ +@Service +public class SystemPromptService { + + private final JdbcTemplate jdbc; + + public SystemPromptService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public String buildSystemPrompt() { + return """ + 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ + 帮助他们查询和(未来)操作 ERP 里的业务单据。 + + 【业务域地图(L1 路由)】 + 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ + 可能涉及哪些单据,再决定怎么做: + %s + 【可用工具】 + - findForms(keyword):按关键词检索业务表单目录,用来把用户口中的「单据 / 报表」定位到具体是哪一张表单\ + (拿到 formId / moduleId)。当你不确定用户指的到底是哪张表单时,先调用它确认,不要自己编表单名。 + + 【行为准则】 + 1. 凡是能用工具确认的事实(表单、单据、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 + 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 + 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 + 4. 你**当前只有只读的「表单目录检索」能力**。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ + 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 + 5. 信息不足时主动向用户提问,不要猜测。 + """.formatted(renderDomainMap()); + } + + private String renderDomainMap() { + List> rows; + try { + rows = jdbc.queryForList( + "SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " + + "FROM viw_kg_domain ORDER BY iForms DESC"); + } catch (Exception e) { + // KG 视图缺失时不应阻断启动——降级为空域图,agent 仍可运行。 + org.slf4j.LoggerFactory.getLogger(SystemPromptService.class) + .warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage()); + return "(业务域地图暂不可用)\n"; + } + StringBuilder sb = new StringBuilder(); + for (Map r : rows) { + sb.append("- ").append(r.get("sDomain")) + .append("(").append(r.get("sAiScene")).append(",") + .append(r.get("iForms")).append(" 张单据)"); + String up = str(r.get("sUpstreamDomains")); + String down = str(r.get("sDownstreamDomains")); + if (notEmpty(up)) sb.append(" 上游←[").append(up).append("]"); + if (notEmpty(down)) sb.append(" 下游→[").append(down).append("]"); + sb.append('\n'); + } + return sb.toString(); + } + + private static boolean notEmpty(String s) { + return s != null && !s.isBlank() && !"NULL".equalsIgnoreCase(s); + } + + private static String str(Object o) { + return o == null ? "" : o.toString(); + } +} diff --git a/src/main/java/com/xly/tool/KgQueryTool.java b/src/main/java/com/xly/tool/KgQueryTool.java new file mode 100644 index 0000000..c898893 --- /dev/null +++ b/src/main/java/com/xly/tool/KgQueryTool.java @@ -0,0 +1,69 @@ +package com.xly.tool; + +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 知识图谱查询工具(M1)。 + * + *

目前只暴露 {@link #findForms} 一个只读能力:按关键词检索 ERP 业务表单目录 + * (视图 {@code viw_ai_useful_forms},1748 张有用业务表单)。这是「表单名 → formId/moduleId」 + * 定位的过渡实现,也是后续 KgSearch / formId 解析的雏形。 + * + *

表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。 + */ +@Component +public class KgQueryTool { + + private final JdbcTemplate jdbc; + + public KgQueryTool(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Tool("按关键词检索 ERP 业务表单目录,返回匹配的表单名、底层数据源、所属菜单id(moduleId) 与表单id(formId)。" + + "当用户提到某类单据或报表、但你不确定具体是哪一张表单时,先用它来定位。最多返回 15 条。") + public String findForms( + @P("表单名或业务关键词,例如:报价 / 客户 / 库存 / 送货 / 应收 / 采购订单") String keyword) { + + if (keyword == null || keyword.isBlank()) { + return "请提供一个表单名或业务关键词再检索。"; + } + String kw = keyword.trim(); + String like = "%" + kw + "%"; + + List> rows = jdbc.queryForList( + "SELECT sFormTitle, sDataSource, sExecType, sModuleId, sFormId " + + "FROM viw_ai_useful_forms " + + "WHERE sFormTitle LIKE ? " + + "ORDER BY CHAR_LENGTH(sFormTitle) ASC " + + "LIMIT 15", + like); + + if (rows.isEmpty()) { + return "没有找到名称包含「" + kw + "」的业务表单。可以换个更常见的说法,或告诉我更具体的单据名称。"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("找到 ").append(rows.size()).append(" 张与「").append(kw).append("」相关的业务表单:\n\n"); + sb.append("| 表单 | 数据源(").append("表/视图/存储过程) | 菜单id | 表单id |\n"); + sb.append("|---|---|---|---|\n"); + for (Map r : rows) { + sb.append("| ").append(str(r.get("sFormTitle"))) + .append(" | ").append(str(r.get("sDataSource"))).append(" · ").append(str(r.get("sExecType"))) + .append(" | ").append(str(r.get("sModuleId"))) + .append(" | ").append(str(r.get("sFormId"))) + .append(" |\n"); + } + return sb.toString(); + } + + private static String str(Object o) { + return o == null ? "" : o.toString(); + } +} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java new file mode 100644 index 0000000..f60e9f8 --- /dev/null +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -0,0 +1,97 @@ +package com.xly.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.ReActAgent; +import dev.langchain4j.service.TokenStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 单 agent 对话入口(M1)。 + * + *

{@code POST /xlyAi/api/agent/chat} 以 SSE(text/event-stream)流式返回助手的 token。 + * 每帧是一条 JSON:{@code {"type":"token|done|error","content":"..."}},前端逐帧渲染。 + * + *

会话记忆按 {@code conversationId} 隔离(多条命名会话);缺省用 {@code userid:default}。 + * 说明:token 流本身是异步的({@link TokenStream#start()} 立即返回,回调在模型线程触发), + * 这里用一个线程池提交,避免占用请求线程。 + */ +@RestController +@RequestMapping("/api/agent") +public class AgentChatController { + + private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); + + private final ReActAgent agent; + private final ObjectMapper mapper; + private final ExecutorService exec = Executors.newCachedThreadPool(); + + public AgentChatController(ReActAgent agent, ObjectMapper mapper) { + this.agent = agent; + this.mapper = mapper; + } + + /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ + public static class ChatReq { + public String text; + public String userid; + public String conversationId; + } + + @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") + public SseEmitter chat(@RequestBody ChatReq req) { + SseEmitter emitter = new SseEmitter(180_000L); + final String userInput = req.text == null ? "" : req.text; + final String convId = (req.conversationId != null && !req.conversationId.isBlank()) + ? req.conversationId + : ((req.userid == null ? "anon" : req.userid) + ":default"); + + exec.submit(() -> { + try { + TokenStream ts = agent.chat(convId, userInput); + ts.onPartialResponse(token -> send(emitter, "token", token)) + // 工具执行 = 一轮结束:此前流出的是模型的“调用旁白/思考”,让前端清空, + // 只保留工具执行之后的最终答复(既去掉噪声、又保留流式)。 + .onToolExecuted(te -> send(emitter, "reset", "")) + .onCompleteResponse(resp -> { + send(emitter, "done", ""); + emitter.complete(); + }) + .onError(err -> { + log.warn("agent chat stream error (conv={})", convId, err); + send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); + emitter.complete(); + }) + .start(); + } catch (Exception e) { + log.error("agent invoke failed (conv={})", convId, e); + send(emitter, "error", "服务异常:" + e.getMessage()); + emitter.complete(); + } + }); + return emitter; + } + + private void send(SseEmitter emitter, String type, String content) { + try { + Map m = new LinkedHashMap<>(); + m.put("type", type); + m.put("content", content); + emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); + } catch (IOException | IllegalStateException e) { + // 客户端已断开或 emitter 已结束——忽略。 + } + } +} diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml new file mode 100644 index 0000000..2e0f6ab --- /dev/null +++ b/src/main/resources/application-saaslocal.yml @@ -0,0 +1,22 @@ +# Local override profile so xlyAi runs against the saas-8s+ local stack on macOS. +# +# Run: JAVA_HOME=$(/usr/libexec/java_home -v 17) ./mvnw spring-boot:run \ +# -Dspring-boot.run.profiles=saaslocal +# +# Only overrides what differs from the committed application.yml (which targets a +# remote Windows deploy). Redis already points at the local shared redis-local +# (127.0.0.1:16379 / db 0) so it is inherited unchanged. Milvus/Ollama stay remote +# — their clients are lazy, so the app boots even if they are unreachable. +logging: + dirpath: /Users/reporkey/Desktop/saas-8s+/logs/xlyAi + +spring: + datasource: + # Local saas DB from docker-compose.saas.yml (mysql-saas, root/local). + url: jdbc:mysql://127.0.0.1:33307/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&allowMultiQueries=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL + username: root + password: local + +# macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp) +ocr: + tmpPath: /Users/reporkey/Desktop/saas-8s+/tempPath/ocrtmp diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 38789c1..7dedb0a 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -461,6 +461,7 @@