From 8d3905a2580b520dce60731d0216bbb1a6414234 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:08:07 +0800 Subject: [PATCH] feat: per-request identity (token pass-through §7) + examine write + AskUser + FormCollect + Skills + KgSearch --- src/main/java/com/xly/agent/AgentIdentity.java | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/config/AgentConfig.java | 40 +++++++--------------------------------- src/main/java/com/xly/config/AgentFactory.java | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/AuthzService.java | 23 +++++++++++++++++++++++ src/main/java/com/xly/service/ErpClient.java | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- src/main/java/com/xly/service/SkillService.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/SystemPromptService.java | 39 +++++++++++++++++++++++++++------------ src/main/java/com/xly/tool/ErpReadTool.java | 18 ++++++++---------- src/main/java/com/xly/tool/FormCollectTool.java | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- src/main/java/com/xly/tool/InteractionTool.java | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/tool/KgQueryTool.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/tool/ProposeWriteTool.java | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- src/main/java/com/xly/tool/QueryTool.java | 8 ++++---- src/main/java/com/xly/tool/SkillTool.java | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/web/AgentChatController.java | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- src/main/java/com/xly/web/OpController.java | 19 ++++++++++++++----- src/main/resources/templates/chat.html | 37 +++++++++++++++++++++++++++++++++++++ 17 files changed, 884 insertions(+), 138 deletions(-) create mode 100644 src/main/java/com/xly/agent/AgentIdentity.java create mode 100644 src/main/java/com/xly/config/AgentFactory.java create mode 100644 src/main/java/com/xly/service/SkillService.java create mode 100644 src/main/java/com/xly/tool/InteractionTool.java create mode 100644 src/main/java/com/xly/tool/SkillTool.java diff --git a/src/main/java/com/xly/agent/AgentIdentity.java b/src/main/java/com/xly/agent/AgentIdentity.java new file mode 100644 index 0000000..ae85318 --- /dev/null +++ b/src/main/java/com/xly/agent/AgentIdentity.java @@ -0,0 +1,78 @@ +package com.xly.agent; + +import java.util.Set; + +/** + * 单次 agent 调用的「按调用上下文」身份(架构 §5/§7 的 per-call context)。 + * + *

由 {@code AgentChatController} 从前端请求解析(透传的 ERP 登录 token + 稳定身份),随 + * {@link com.xly.config.AgentFactory} 注入到**每请求新建**的工具实例里——因为 LangChain4j 流式 + * 工具执行发生在模型 HTTP 回调线程而非控制器工作线程,ThreadLocal 不可靠,只能把身份放进工具实例本身。 + * + *

{@link #token} = 透传的用户 ERP 会话 token(可空 → 退回 dev-login)。{@link #grantedModuleIds} + * = 该用户实际有权的表单/菜单 id 集合({@code null} = 管理员/全部),Read/Query/Write 共用此边界。 + * token 绝不进 prompt / LLM 可见文本。 + */ +public final class AgentIdentity { + + private final String token; + private final String userId; + private final String userNo; + private final String brandsId; + private final String subsidiaryId; + private final String userType; + private final Set grantedModuleIds; // null = 全部(管理员) + + public AgentIdentity(String token, String userId, String userNo, String brandsId, + String subsidiaryId, String userType, Set grantedModuleIds) { + this.token = token; + this.userId = userId; + this.userNo = userNo; + this.brandsId = brandsId; + this.subsidiaryId = subsidiaryId; + this.userType = userType; + this.grantedModuleIds = grantedModuleIds; + } + + /** 透传的用户 token;为空表示回退到 dev-login(本地开发)。 */ + public String token() { + return token; + } + + public boolean hasUserToken() { + return token != null && !token.isBlank(); + } + + public String userId() { + return userId; + } + + public String userNo() { + return userNo; + } + + public String brandsId() { + return brandsId; + } + + public String subsidiaryId() { + return subsidiaryId; + } + + public String userType() { + return userType; + } + + /** null = 全部权限(管理员)。 */ + public Set grantedModuleIds() { + return grantedModuleIds; + } + + public boolean isAdminAll() { + return grantedModuleIds == null; + } + + public boolean canAccessModule(String moduleId) { + return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId)); + } +} diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 153afe6..340e5f1 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -1,25 +1,19 @@ package com.xly.config; -import com.xly.agent.ReActAgent; -import com.xly.service.SystemPromptService; -import com.xly.tool.ErpReadTool; -import com.xly.tool.KgQueryTool; -import com.xly.tool.ProposeWriteTool; -import com.xly.tool.QueryTool; -import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; -import dev.langchain4j.service.AiServices; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; +import java.util.List; /** - * 组装单一 ReAct agent(M1)。 + * agent 用的流式模型 Bean。 * - *

= 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆 - * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。 + *

单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例, + * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。 */ @Configuration public class AgentConfig { @@ -30,7 +24,7 @@ public class AgentConfig { @Value("${langchain4j.ollama.chat-model-name}") private String chatModelName; - @org.springframework.beans.factory.annotation.Autowired + @Autowired private TracingChatModelListener tracingListener; /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ @@ -45,28 +39,8 @@ public class AgentConfig { // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 .think(false) .returnThinking(false) - .listeners(java.util.List.of(tracingListener)) + .listeners(List.of(tracingListener)) .timeout(Duration.ofSeconds(180)) .build(); } - - @Bean - public ReActAgent reActAgent(SystemPromptService systemPromptService, - KgQueryTool kgQueryTool, - ErpReadTool erpReadTool, - ProposeWriteTool proposeWriteTool, - QueryTool queryTool, - RedisChatMemoryStore memoryStore) { - String systemPrompt = systemPromptService.buildSystemPrompt(); - return AiServices.builder(ReActAgent.class) - .streamingChatModel(agentStreamingModel()) - .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool) - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() - .id(memoryId) - .maxMessages(30) - .chatMemoryStore(memoryStore) - .build()) - .systemMessageProvider(memoryId -> systemPrompt) - .build(); - } } diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java new file mode 100644 index 0000000..bfb5a8c --- /dev/null +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -0,0 +1,117 @@ +package com.xly.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; +import com.xly.agent.ReActAgent; +import com.xly.service.AuditService; +import com.xly.service.ErpClient; +import com.xly.service.FormResolverService; +import com.xly.service.OpService; +import com.xly.service.SkillService; +import com.xly.service.SystemPromptService; +import com.xly.tool.ErpReadTool; +import com.xly.tool.FormCollectTool; +import com.xly.tool.InteractionTool; +import com.xly.tool.KgQueryTool; +import com.xly.tool.ProposeWriteTool; +import com.xly.tool.QueryTool; +import com.xly.tool.SkillTool; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.ollama.OllamaChatModel; +import dev.langchain4j.model.ollama.OllamaStreamingChatModel; +import dev.langchain4j.service.AiServices; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 按「每次请求的身份」组装单一 ReAct agent(架构 §5/§7 的 per-call context)。 + * + *

为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, + * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 + * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。 + * + *

无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、 + * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。 + */ +@Component +public class AgentFactory { + + private final OllamaStreamingChatModel streamingModel; + private final OllamaChatModel sqlModel; + private final RedisChatMemoryStore memoryStore; + private final SystemPromptService systemPromptService; + + private final ErpClient erp; + private final JdbcTemplate jdbc; + private final FormResolverService resolver; + private final OpService ops; + private final ObjectMapper mapper; + private final AuditService audit; + private final SkillService skillService; + + private final KgQueryTool kgQueryTool; + private final SkillTool skillTool; + private final InteractionTool interactionTool; + + private volatile String cachedSystemPrompt; + + public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel, + @Qualifier("sqlChatModel") OllamaChatModel sqlModel, + RedisChatMemoryStore memoryStore, + SystemPromptService systemPromptService, + ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, + ObjectMapper mapper, AuditService audit, SkillService skillService, + KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) { + this.streamingModel = streamingModel; + this.sqlModel = sqlModel; + this.memoryStore = memoryStore; + this.systemPromptService = systemPromptService; + this.erp = erp; + this.jdbc = jdbc; + this.resolver = resolver; + this.ops = ops; + this.mapper = mapper; + this.audit = audit; + this.skillService = skillService; + this.kgQueryTool = kgQueryTool; + this.skillTool = skillTool; + this.interactionTool = interactionTool; + } + + /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */ + private String systemPrompt() { + String p = cachedSystemPrompt; + if (p == null) { + synchronized (this) { + if (cachedSystemPrompt == null) { + cachedSystemPrompt = systemPromptService.buildSystemPrompt(); + } + p = cachedSystemPrompt; + } + } + return p; + } + + /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */ + public ReActAgent build(AgentIdentity identity) { + String systemPrompt = systemPrompt(); + return AiServices.builder(ReActAgent.class) + .streamingChatModel(streamingModel) + .tools( + kgQueryTool, + skillTool, + interactionTool, + new ErpReadTool(erp, jdbc, resolver, identity), + new ProposeWriteTool(erp, jdbc, ops, mapper, identity), + new QueryTool(sqlModel, jdbc, audit, identity), + new FormCollectTool(erp, jdbc, resolver, identity, mapper)) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(30) + .chatMemoryStore(memoryStore) + .build()) + .systemMessageProvider(memoryId -> systemPrompt) + .build(); + } +} diff --git a/src/main/java/com/xly/service/AuthzService.java b/src/main/java/com/xly/service/AuthzService.java index 2086d9b..f9715a7 100644 --- a/src/main/java/com/xly/service/AuthzService.java +++ b/src/main/java/com/xly/service/AuthzService.java @@ -36,6 +36,9 @@ public class AuthzService { @Value("${erp.dev-login.usertype:sysadmin}") private String devUserType; + @Value("${erp.dev-login.userid:}") + private String devUserIdOverride; + public AuthzService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @@ -46,6 +49,26 @@ public class AuthzService { return isAllowed(granted, moduleId); } + /** + * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析 + * (admin → null = 全部)。 + */ + public com.xly.agent.AgentIdentity devIdentity() { + String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); + Set granted = grantedIds(uid, devUserType, devBrand, devSub); + return new com.xly.agent.AgentIdentity(null, uid, devUserNo, devBrand, devSub, devUserType, granted); + } + + /** + * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户 + * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。 + */ + public com.xly.agent.AgentIdentity userIdentity(String token, String userId, String userNo, + String brandsId, String subsidiaryId, String userType) { + Set granted = grantedIds(userId, userType, brandsId, subsidiaryId); + return new com.xly.agent.AgentIdentity(token, userId, userNo, brandsId, subsidiaryId, userType, granted); + } + /** null = 全部(管理员);否则 = 有权的 id 集合。 */ public Set grantedIds(String userId, String userType, String brandsId, String subsidiaryId) { if (isAdmin(userType)) { diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 55059d4..5009b2b 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -85,16 +85,38 @@ public class ErpClient { return (t != null && !t.isBlank()) ? t : login(); } + /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */ + private String resolveToken(String override) { + return (override != null && !override.isBlank()) ? override : token(); + } + /** - * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 - * 会话过期(code=-2)时自动重登一次并重试。 + * 是否允许在 code=-2(会话过期)时重登重试。 + *

安全:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时 + * 绝不用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回, + * 由上层向对话推「登录过期」。 */ + private boolean canRelogin(String override) { + return override == null || override.isBlank(); + } + + /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */ public JsonNode readForm(String formId, String moduleId, int page, int pageSize, String filterField, String filterValue) { - JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); - if (root.path("code").asInt() == -2) { + return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue); + } + + /** + * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 + * {@code authToken} 为透传的用户 token(可空 → dev-login)。dev-login 会话过期(code=-2)时自动重登重试; + * 用户 token 过期不重登(见 {@link #canRelogin})。 + */ + public JsonNode readForm(String authToken, String formId, String moduleId, int page, int pageSize, + String filterField, String filterValue) { + JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { login(); - root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); + root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken)); } return root; } @@ -132,24 +154,67 @@ public class ErpClient { * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 */ public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { - JsonNode root = doUpdate(moduleId, table, billId, field, value, token()); - if (root.path("code").asInt() == -2) { + return updateForm(null, moduleId, table, billId, field, value); + } + + public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) { + JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { login(); - root = doUpdate(moduleId, table, billId, field, value, token()); + root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); } return root; } /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ public JsonNode deleteForm(String moduleId, String table, String billId) { - JsonNode root = doDelete(moduleId, table, billId, token()); - if (root.path("code").asInt() == -2) { + return deleteForm(null, moduleId, table, billId); + } + + public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) { + JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { + login(); + root = doDelete(moduleId, table, billId, resolveToken(authToken)); + } + return root; + } + + /** + * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。 + * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。 + */ + public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) { + JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { login(); - root = doDelete(moduleId, table, billId, token()); + root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); } return root; } + private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) { + try { + String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId; + Map paramsMap = new LinkedHashMap<>(); + paramsMap.put("sFormGuid", moduleId); + paramsMap.put("sGuid", billId); + paramsMap.put("iFlag", iFlag); + paramsMap.put("sSlaveId", ""); + String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap)); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(60)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return mapper.readTree(resp.body()); + } catch (Exception e) { + throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e); + } + } + private JsonNode doDelete(String moduleId, String table, String billId, String tok) { try { String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; @@ -176,9 +241,13 @@ public class ErpClient { /** 取一个新主键 uuid(ERP `/getUuid`)。 */ public String newUuid() { + return newUuid(null); + } + + public String newUuid(String authToken) { try { HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) - .header("Authorization", token()) + .header("Authorization", resolveToken(authToken)) .timeout(Duration.ofSeconds(15)) .GET() .build(); @@ -192,10 +261,14 @@ public class ErpClient { /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ public JsonNode createForm(String table, Map columns) { - JsonNode root = doCreate(table, columns, token()); - if (root.path("code").asInt() == -2) { + return createForm(null, table, columns); + } + + public JsonNode createForm(String authToken, String table, Map columns) { + JsonNode root = doCreate(table, columns, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { login(); - root = doCreate(table, columns, token()); + root = doCreate(table, columns, resolveToken(authToken)); } return root; } diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java new file mode 100644 index 0000000..ec7ecff --- /dev/null +++ b/src/main/java/com/xly/service/SkillService.java @@ -0,0 +1,57 @@ +package com.xly.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * Skill 注册表服务(架构 §6)。 + * + *

Skill = 针对重复任务的 playbook(新建报价 / 月度对账 / 库存盘点)。渐进披露: + * {@link #promptDigest()} 把「name + 何时用」摘要注入 system prompt(便宜、常驻); + * {@link #load(String)} 在 {@code load_skill} 工具被调用时返回完整指令。数据在 {@code ai_skill} 表。 + */ +@Service +public class SkillService { + + private final JdbcTemplate jdbc; + + public SkillService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** 供 system prompt 的 Skill 摘要(name + 何时用),一行一个。KG/表缺失时降级为空串。 */ + public String promptDigest() { + try { + List> rows = jdbc.queryForList( + "SELECT sName, sWhenToUse FROM ai_skill WHERE bEnabled=1 ORDER BY iOrder"); + if (rows.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Map r : rows) { + sb.append("- ").append(r.get("sName")).append(":").append(r.get("sWhenToUse")).append("\n"); + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + /** 按名称加载完整 playbook(精确优先,模糊兜底)。返回 null = 找不到。 */ + public Map load(String name) { + if (name == null || name.isBlank()) { + return null; + } + List> r = jdbc.queryForList( + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName=? LIMIT 1", name.trim()); + if (r.isEmpty()) { + r = jdbc.queryForList( + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName LIKE ? ORDER BY iOrder LIMIT 1", + "%" + name.trim() + "%"); + } + return r.isEmpty() ? null : r.get(0); + } +} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index ef001ed..9301565 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -9,17 +9,20 @@ import java.util.Map; /** * 构建单 agent 的 system prompt。 * - *

核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)渲染进 - * system prompt,作为常驻的「路由地图」——让单 agent 先判断问题属于哪个业务域、涉及哪些单据, - * 再决定调用哪个工具。L1 体量小且永远相关,适合常驻 prompt;L2/L3 大而稀疏,走工具按需查。 + *

核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)与 + * Skill 摘要({@code ai_skill} 的 name+何时用)渲染进 system prompt,作为常驻的「路由地图 + 技能目录」—— + * 让单 agent 先判断问题属于哪个业务域/是否命中某 Skill,再决定调哪个工具。L1/Skill 摘要体量小且永远相关, + * 适合常驻;L2/L3 与 Skill 详情大而稀疏,走工具(KgSearch / load_skill)按需查。 */ @Service public class SystemPromptService { private final JdbcTemplate jdbc; + private final SkillService skills; - public SystemPromptService(JdbcTemplate jdbc) { + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { this.jdbc = jdbc; + this.skills = skills; } public String buildSystemPrompt() { @@ -27,24 +30,31 @@ public class SystemPromptService { 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ - 帮助他们查询和(未来)操作 ERP 里的业务单据。 + 帮助他们查询和操作 ERP 里的业务单据。 【业务域地图(L1 路由)】 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ 可能涉及哪些单据,再决定怎么做: %s + 【可用技能 Skills】 + 下面是一些常见任务的技能。若用户需求命中某个技能的「何时用」,先调用 loadSkill(该技能名) 拿到详细步骤再照做: + %s 【可用工具】 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 + - kgSearch(keyword):查知识图谱——某表单的上下游流转、相邻单据、字段所在的表/列。想弄清「这张单从哪来、到哪去」或「某字段在哪张表」时用。 - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ 仅当 readFormData / lookupRecord 都答不了时才用。 - - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ - (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 - - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 + - askUser(question, options?):意图不明或需二选一时,向用户提一个带选项的小问题;问完即等用户回答。 + - collectForm(entityKeyword):新增字段较多的单据(如报价)时,弹一张表单让用户一次填齐;提交后你再用 proposeCreate。 - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 + - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段(写操作,只提议、暂不执行)。 + - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行)。删除不可恢复,慎用。 + - proposeExamine(entityKeyword, recordKeyword):**提议审核/过账**某张单据(写操作,只提议、暂不执行)。审核有业务后果,慎用。 + - loadSkill(name):加载某个业务技能的完整操作指南(见上方 Skills 清单)。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 @@ -53,11 +63,16 @@ public class SystemPromptService { 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ - 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete、审核用 proposeExamine。\ + 它们都**只生成待确认提议、不立即执行**;真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ - 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 - """.formatted(renderDomainMap()); + 只有确实缺少关键参数(如不知道要查哪张单据)时才用 askUser 提问。 + """.formatted(renderDomainMap(), renderSkills()); + } + + private String renderSkills() { + String digest = skills.promptDigest(); + return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest; } private String renderDomainMap() { diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index 892365a..75250a1 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -1,13 +1,12 @@ package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; -import com.xly.service.AuthzService; +import com.xly.agent.AgentIdentity; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; 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.ArrayList; import java.util.HashMap; @@ -25,7 +24,6 @@ import java.util.stream.Collectors; * *

安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 */ -@Component public class ErpReadTool { private static final int MAX_ROWS = 10; @@ -34,13 +32,13 @@ public class ErpReadTool { private final ErpClient erp; private final JdbcTemplate jdbc; private final FormResolverService resolver; - private final AuthzService authz; + private final AgentIdentity identity; - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AuthzService authz) { + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity) { this.erp = erp; this.jdbc = jdbc; this.resolver = resolver; - this.authz = authz; + this.identity = identity; } @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" @@ -58,14 +56,14 @@ public class ErpReadTool { String formId = String.valueOf(form.get("sFormId")); String moduleId = String.valueOf(form.get("sModuleId")); String table = String.valueOf(form.get("sDataSource")); - if (!authz.canAccessModule(moduleId)) { + if (!identity.canAccessModule(moduleId)) { return "你没有访问「" + entityKeyword + "」的权限。"; } String nameField = resolver.resolveNameField(table); JsonNode root; try { - root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim()); + root = erp.readForm(identity.token(), formId, moduleId, 1, 3, nameField, recordKeyword.trim()); } catch (Exception e) { return "读取失败:" + e.getMessage(); } @@ -125,7 +123,7 @@ public class ErpReadTool { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; } - if (!authz.canAccessModule(moduleId.trim())) { + if (!identity.canAccessModule(moduleId.trim())) { return "你没有访问该表单的权限。"; } String kw = (keyword == null) ? "" : keyword.trim(); @@ -133,7 +131,7 @@ public class ErpReadTool { JsonNode root; try { - root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); } catch (Exception e) { return "读取失败:" + e.getMessage(); } diff --git a/src/main/java/com/xly/tool/FormCollectTool.java b/src/main/java/com/xly/tool/FormCollectTool.java index 741444d..3fc89f5 100644 --- a/src/main/java/com/xly/tool/FormCollectTool.java +++ b/src/main/java/com/xly/tool/FormCollectTool.java @@ -1,75 +1,166 @@ package com.xly.tool; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; +import com.xly.service.ErpClient; import com.xly.service.FormResolverService; 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.ArrayList; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; /** - * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 + * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, + * 而不是逐字段追问。字段/类型/必填/下拉/默认来自 ERP 表单元数据({@code gdsconfigformslave})。 * - *

字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段), - * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走 - * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 + *

工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 + * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回, + * agent 再据此走 {@code proposeCreate}(人在环写入)。适合报价这类字段多的新建场景。 + * + *

由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 */ -@Component public class FormCollectTool { - private final FormResolverService resolver; + private static final int MAX_FIELDS = 40; + + private final ErpClient erp; private final JdbcTemplate jdbc; + private final FormResolverService resolver; + private final AgentIdentity identity; private final ObjectMapper mapper; - public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) { - this.resolver = resolver; + public FormCollectTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, + AgentIdentity identity, ObjectMapper mapper) { + this.erp = erp; this.jdbc = jdbc; + this.resolver = resolver; + this.identity = identity; this.mapper = mapper; } - @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。" - + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。") - public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { + @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问)。用于字段较多的新建/录入场景" + + "(如新建报价、新建客户)。入参 = 实体/单据类型(如 报价 / 客户)。用户填完提交后,你再用 proposeCreate 生成待确认的新增。") + public String collectForm(@P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword) { if (entityKeyword == null || entityKeyword.isBlank()) { - return "{\"error\":\"缺少实体类型\"}"; + return err("缺少实体类型。"); } Map form = resolver.resolveMasterForm(entityKeyword.trim()); if (form == null) { - return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; + return err("找不到「" + entityKeyword + "」对应的可新建表单。"); + } + String formId = String.valueOf(form.get("sFormId")); + String moduleId = String.valueOf(form.get("sModuleId")); + if (!identity.canAccessModule(moduleId)) { + return err("你没有新建「" + entityKeyword + "」的权限。"); } - String table = String.valueOf(form.get("sDataSource")); - List fields = new ArrayList<>(); - Set seen = new HashSet<>(); + + List> cols; try { - List> rows = jdbc.queryForList( - "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " + - "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " + - "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table); - for (Map r : rows) { - Object zh = r.get("sChinese"); - if (zh != null && seen.add(zh.toString()) && fields.size() < 8) { - fields.add(zh.toString()); - } + cols = jdbc.queryForList( + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " + + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " + + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); + } catch (Exception e) { + return err("读取表单结构失败:" + e.getMessage()); + } + if (cols.isEmpty()) { + return err("该表单没有可填写的字段配置。"); + } + + List> fields = new ArrayList<>(); + for (Map c : cols) { + String name = str(c.get("sName")); + if (name == null || name.isBlank()) { + continue; + } + Map f = new LinkedHashMap<>(); + f.put("name", name); + f.put("label", firstNonBlank(str(c.get("sChinese")), name)); + f.put("control", firstNonBlank(str(c.get("sControlName")), "text")); + f.put("required", truthy(c.get("bNotEmpty"))); + String def = str(c.get("sDefault")); + if (def != null && !def.isBlank()) { + f.put("default", def); + } + List opts = simpleOptions(str(c.get("sChineseDropDown"))); + if (!opts.isEmpty()) { + f.put("options", opts); } - } catch (Exception ignore) { + fields.add(f); } if (fields.isEmpty()) { - return "{\"error\":\"该实体没有可填字段\"}"; + return err("该表单没有可填写的字段。"); } + Map out = new LinkedHashMap<>(); - out.put("formCollect", true); + out.put("type", "form_collect"); out.put("entity", entityKeyword.trim()); + out.put("formId", formId); + out.put("moduleId", moduleId); + out.put("title", "新建" + entityKeyword.trim()); out.put("fields", fields); - out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); + out.put("message", "请在下方表单里填写,填完点【提交】。"); + return toJson(out); + } + + /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */ + private List simpleOptions(String dropdown) { + List out = new ArrayList<>(); + if (dropdown == null || dropdown.isBlank()) { + return out; + } + String d = dropdown.trim(); + if (d.toLowerCase().contains("select ") || d.length() > 200) { + return out; // SQL 或过长 -> 不当作枚举 + } + for (String o : d.split("[、,,|]")) { + String t = o.trim(); + if (!t.isEmpty() && out.size() < 30) { + out.add(t); + } + } + return out; + } + + private static boolean truthy(Object o) { + if (o == null) { + return false; + } + if (o instanceof Boolean) { + return (Boolean) o; + } + if (o instanceof Number) { + return ((Number) o).intValue() != 0; + } + if (o instanceof byte[]) { + byte[] b = (byte[]) o; + return b.length > 0 && b[0] != 0; + } + String s = o.toString().trim(); + return s.equals("1") || s.equalsIgnoreCase("true"); + } + + private static String firstNonBlank(String a, String b) { + return (a != null && !a.isBlank()) ? a : b; + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + private String err(String msg) { + Map m = new LinkedHashMap<>(); + m.put("error", msg); + return toJson(m); + } + + private String toJson(Map m) { try { - return mapper.writeValueAsString(out); + return mapper.writeValueAsString(m); } catch (Exception e) { return "{\"error\":\"内部错误\"}"; } diff --git a/src/main/java/com/xly/tool/InteractionTool.java b/src/main/java/com/xly/tool/InteractionTool.java new file mode 100644 index 0000000..30c008b --- /dev/null +++ b/src/main/java/com/xly/tool/InteractionTool.java @@ -0,0 +1,54 @@ +package com.xly.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * AskUser 工具(架构 §5 #4)——消歧 / 澄清用的小问题:一句问题 + 若干可点选项(也允许自由输入)。 + * + *

工具返回结构化 JSON;{@code AgentChatController} 侦测到 {@code type=question} 后推一条 SSE + * {@code question} 事件,前端渲染可点选项片。用户点选/输入的答复作为下一条消息进入对话,凭 ChatMemory + * 自然接续(无需额外的 pending 状态机即可完成一轮澄清)。工具无状态、单例。 + */ +@Component +public class InteractionTool { + + private final ObjectMapper mapper; + + public InteractionTool(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Tool("向用户提出一个澄清/消歧的小问题并给出可选项。当用户意图不明确、或需要在几个候选中二选一时使用;" + + "问完即结束本轮、等待用户回答。options 用中文顿号或逗号分隔(如「本月、上月、本季度」),可留空表示自由回答。") + public String askUser( + @P("要问用户的问题") String question, + @P(value = "候选选项,用、或,分隔;没有明确候选时留空", required = false) String options) { + + List opts = new ArrayList<>(); + if (options != null && !options.isBlank()) { + for (String o : options.split("[、,,]")) { + String t = o.trim(); + if (!t.isEmpty()) { + opts.add(t); + } + } + } + Map out = new LinkedHashMap<>(); + out.put("type", "question"); + out.put("question", question == null ? "" : question.trim()); + out.put("options", opts); + try { + return mapper.writeValueAsString(out); + } catch (Exception e) { + return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[]}"; + } + } +} diff --git a/src/main/java/com/xly/tool/KgQueryTool.java b/src/main/java/com/xly/tool/KgQueryTool.java index 16ab970..590ea4b 100644 --- a/src/main/java/com/xly/tool/KgQueryTool.java +++ b/src/main/java/com/xly/tool/KgQueryTool.java @@ -65,6 +65,79 @@ public class KgQueryTool { return sb.toString(); } + @Tool("查知识图谱(KG):某表单的**上下游流转/相邻单据**(L2)与某字段**在哪些表/列**(L3)。" + + "用于弄清「这张单从哪来、到哪去、和谁相关」或「某个字段落在哪张表」。入参 = 表单名或字段中文名关键词。") + public String kgSearch(@P("表单名或字段中文名关键词,如 采购订单 / 送货 / 单价 / 数量") String keyword) { + if (keyword == null || keyword.isBlank()) { + return "请提供一个表单名或字段中文名关键词。"; + } + String kw = keyword.trim(); + String like = "%" + kw + "%"; + StringBuilder sb = new StringBuilder(); + + // L2:表单邻居 / 上下游流转 + List> flow = safeQuery( + "SELECT sFormTitle, sDomain, iUpstream, iDownstream, sUpForms, sDownForms, sRefTables " + + "FROM viw_kg_form_neighbors WHERE sFormTitle LIKE ? " + + "ORDER BY (COALESCE(iUpstream,0)+COALESCE(iDownstream,0)) DESC LIMIT 5", like); + if (!flow.isEmpty()) { + sb.append("【单据流转 / 相邻单据】\n"); + for (Map r : flow) { + sb.append("- ").append(str(r.get("sFormTitle"))) + .append("(域:").append(str(r.get("sDomain"))).append(")"); + String up = clip(str(r.get("sUpForms")), 80); + String down = clip(str(r.get("sDownForms")), 80); + if (!up.isBlank()) sb.append("\n 上游←:").append(up); + if (!down.isBlank()) sb.append("\n 下游→:").append(down); + String ref = clip(str(r.get("sRefTables")), 80); + if (!ref.isBlank()) sb.append("\n 引用表:").append(ref); + sb.append('\n'); + } + } + + // L3:字段 -> 表/列(按词查,绝不整表 dump) + List> fields = safeQuery( + "SELECT sTable, sField, sChinese, sFkTable FROM viw_kg_field_dict " + + "WHERE sChinese LIKE ? AND sTable NOT LIKE 'viw%' " + + "ORDER BY iFormUses DESC LIMIT 8", like); + if (!fields.isEmpty()) { + sb.append("\n【字段所在表/列】\n"); + for (Map r : fields) { + sb.append("- ").append(str(r.get("sChinese"))) + .append(" = ").append(str(r.get("sTable"))).append(".").append(str(r.get("sField"))); + String fk = str(r.get("sFkTable")); + if (fk != null && !fk.isBlank() && !"null".equalsIgnoreCase(fk)) { + sb.append("(外键→").append(fk).append(")"); + } + sb.append('\n'); + } + } + + if (sb.length() == 0) { + return "知识图谱里没有与「" + kw + "」直接相关的流转或字段。可以换个更常见的单据名或字段名。"; + } + return sb.toString(); + } + + private List> safeQuery(String sql, Object... args) { + try { + return jdbc.queryForList(sql, args); + } catch (Exception e) { + return List.of(); + } + } + + private static String clip(String s, int max) { + if (s == null) { + return ""; + } + String t = s.trim(); + if ("null".equalsIgnoreCase(t)) { + return ""; + } + return t.length() > max ? t.substring(0, max) + "…" : t; + } + private static String str(Object o) { return o == null ? "" : o.toString(); } diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 33fe3ff..0bf50dd 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -2,13 +2,12 @@ package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.xly.service.AuthzService; +import com.xly.agent.AgentIdentity; import com.xly.service.ErpClient; import com.xly.service.OpService; 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.ArrayList; import java.util.Iterator; @@ -24,21 +23,20 @@ import java.util.Set; * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 * (见 OpController),不经过 LLM。 */ -@Component public class ProposeWriteTool { private final ErpClient erp; private final JdbcTemplate jdbc; private final OpService ops; private final ObjectMapper mapper; - private final AuthzService authz; + private final AgentIdentity identity; - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) { + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AgentIdentity identity) { this.erp = erp; this.jdbc = jdbc; this.ops = ops; this.mapper = mapper; - this.authz = authz; + this.identity = identity; } @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" @@ -62,7 +60,7 @@ public class ProposeWriteTool { String formId = str(form.get("sFormId")); String moduleId = str(form.get("sModuleId")); String table = str(form.get("sDataSource")); - if (!authz.canAccessModule(moduleId)) { + if (!identity.canAccessModule(moduleId)) { return err("你没有修改「" + entityKeyword + "」的权限。"); } @@ -85,7 +83,7 @@ public class ProposeWriteTool { "ORDER BY iFormUses DESC LIMIT 1", table); JsonNode root; try { - root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); } catch (Exception e) { return err("定位记录时读取失败:" + e.getMessage()); } @@ -118,7 +116,7 @@ public class ProposeWriteTool { // 4) 暂存 draft(不执行) String description = "将【" + recordName + "】的【" + fieldChinese + "】" + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; - String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId, + String opId = ops.createDraft(identity.userId(), "update", formId.trim(), moduleId.trim(), table, billId, field, fieldChinese, oldValue, newValue, description); Map out = new LinkedHashMap<>(); @@ -144,7 +142,7 @@ public class ProposeWriteTool { String formId = str(form.get("sFormId")); String moduleId = str(form.get("sModuleId")); String table = str(form.get("sDataSource")); - if (!authz.canAccessModule(moduleId)) { + if (!identity.canAccessModule(moduleId)) { return err("你没有操作「" + entityKeyword + "」的权限。"); } String nameField = queryOne( @@ -152,7 +150,7 @@ public class ProposeWriteTool { "ORDER BY iFormUses DESC LIMIT 1", table); JsonNode root; try { - root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim()); + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); } catch (Exception e) { return err("定位记录失败:" + e.getMessage()); } @@ -180,7 +178,7 @@ public class ProposeWriteTool { } String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); String description = "删除【" + recordName + "】(" + entityKeyword + ")"; - String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId, + String opId = ops.createDraft(identity.userId(), "delete", formId, moduleId, table, billId, null, null, recordName, null, description); Map out = new LinkedHashMap<>(); @@ -206,7 +204,7 @@ public class ProposeWriteTool { String formId = str(form.get("sFormId")); String moduleId = str(form.get("sModuleId")); String table = str(form.get("sDataSource")); - if (!authz.canAccessModule(moduleId)) { + if (!identity.canAccessModule(moduleId)) { return err("你没有新增「" + entityKeyword + "」的权限。"); } @@ -242,7 +240,7 @@ public class ProposeWriteTool { continue; } if ("sId".equals(rc)) { - col.put(rc, erp.newUuid()); + col.put(rc, erp.newUuid(identity.token())); } else if (rc.endsWith("Id")) { String d = commonValue(table, rc); // 外键:取现有最常见值兜底 col.put(rc, d == null ? "" : d); @@ -252,7 +250,7 @@ public class ProposeWriteTool { col.put(rc, ""); } } - col.putIfAbsent("sId", erp.newUuid()); + col.putIfAbsent("sId", erp.newUuid(identity.token())); String payload; try { @@ -261,7 +259,7 @@ public class ProposeWriteTool { return err("内部错误:" + e.getMessage()); } String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); - String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); + String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description); Map out = new LinkedHashMap<>(); out.put("opId", opId); @@ -270,6 +268,69 @@ public class ProposeWriteTool { return toJson(out); } + @Tool("提议**审核/过账**某条单据(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正审核。" + + "用于「审核 / 过账某张单据」这类需求;审核有业务后果,请谨慎。给出单据类型与单号/名称即可。") + public String proposeExamine( + @P("单据类型,如 销售订单 / 采购订单 / 报价") String entityKeyword, + @P("要审核的单据编号或名称关键词") String recordKeyword) { + + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { + return err("缺少单据类型或单号/名称。"); + } + Map form = resolveForm(entityKeyword.trim()); + if (form == null) { + return err("找不到「" + entityKeyword + "」对应的可审核单据主表。"); + } + String formId = str(form.get("sFormId")); + String moduleId = str(form.get("sModuleId")); + String table = str(form.get("sDataSource")); + if (!identity.canAccessModule(moduleId)) { + return err("你没有审核「" + entityKeyword + "」的权限。"); + } + String nameField = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + + "ORDER BY iFormUses DESC LIMIT 1", table); + JsonNode root; + try { + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + return err("定位单据失败:" + e.getMessage()); + } + if (root.path("code").asInt(0) < 0) { + return err("定位单据失败:" + root.path("msg").asText("未知错误")); + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + return err("没有找到含「" + recordKeyword + "」的单据。"); + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); + } + return err("匹配到多条单据(" + names + "),请提供更精确的单号,只审核其中一条。"); + } + JsonNode rec = data.get(0); + String billId = rec.path("sId").asText(null); + if (isBlank(billId)) { + return err("定位到的单据缺少主键 sId,无法审核。"); + } + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); + String description = "审核【" + recordName + "】(" + entityKeyword + ")"; + // examine:sNewValue 存 iFlag(1=审核);执行走 ERP doExamine(存储过程驱动) + String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, + null, null, null, "1", description); + + Map out = new LinkedHashMap<>(); + out.put("opId", opId); + out.put("summary", description); + out.put("message", "已为你生成一条待确认的审核,请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); + return toJson(out); + } + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ private List requiredCols(String table) { List out = new ArrayList<>(); diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java index dc5f23b..00d475a 100644 --- a/src/main/java/com/xly/tool/QueryTool.java +++ b/src/main/java/com/xly/tool/QueryTool.java @@ -1,5 +1,6 @@ package com.xly.tool; +import com.xly.agent.AgentIdentity; import com.xly.service.AuditService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; @@ -7,9 +8,7 @@ import dev.langchain4j.model.ollama.OllamaChatModel; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.select.Select; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; @@ -22,17 +21,18 @@ import java.util.Map; * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 */ -@Component public class QueryTool { private final OllamaChatModel sqlModel; private final JdbcTemplate jdbc; private final AuditService audit; + private final AgentIdentity identity; - public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) { + public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity) { this.sqlModel = sqlModel; this.jdbc = jdbc; this.audit = audit; + this.identity = identity; } @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" diff --git a/src/main/java/com/xly/tool/SkillTool.java b/src/main/java/com/xly/tool/SkillTool.java new file mode 100644 index 0000000..745dd09 --- /dev/null +++ b/src/main/java/com/xly/tool/SkillTool.java @@ -0,0 +1,42 @@ +package com.xly.tool; + +import com.xly.service.SkillService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Skill 加载工具(架构 §6,渐进披露)。 + * + *

system prompt 里只常驻 Skill 的「name + 何时用」摘要;当用户的需求命中某个 Skill 时,agent 调 + * {@code load_skill(name)} 拉取该 Skill 的完整 playbook(详细步骤 + 建议工具)再照做。Skill 是无状态、 + * 全局的,因此本工具是单例。 + */ +@Component +public class SkillTool { + + private final SkillService skills; + + public SkillTool(SkillService skills) { + this.skills = skills; + } + + @Tool("加载某个业务 Skill(playbook)的完整操作指南。当用户的需求匹配 system prompt 中列出的某个 Skill 名时," + + "先调用它拿到详细步骤,再按步骤用其它工具完成。入参 = Skill 名称(如 新建报价 / 月度对账 / 库存查询)。") + public String loadSkill(@P("Skill 名称") String name) { + Map s = skills.load(name); + if (s == null) { + return "没有找到名为「" + name + "」的 Skill。可用的 Skill 见系统提示里的清单。"; + } + String instr = String.valueOf(s.get("sInstructions")); + Object suggested = s.get("sSuggested"); + StringBuilder sb = new StringBuilder(); + sb.append("【Skill:").append(s.get("sName")).append("】\n").append(instr); + if (suggested != null && !String.valueOf(suggested).isBlank()) { + sb.append("\n建议用到的工具:").append(suggested); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index d8e107d..c9e6f81 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -2,7 +2,10 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; import com.xly.agent.ReActAgent; +import com.xly.config.AgentFactory; +import com.xly.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.OpService; import dev.langchain4j.service.TokenStream; @@ -19,31 +22,39 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * - *

帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}。 - * {@code reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片 - * (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。 + *

帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、 + * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。 + * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 + * + *

**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份, + * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。 */ @RestController @RequestMapping("/api/agent") public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); + private static final Set WRITE_TOOLS = + Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine"); - private final ReActAgent agent; + private final AgentFactory agentFactory; + private final AuthzService authz; private final ObjectMapper mapper; private final ConversationService conversations; private final OpService ops; private final ExecutorService exec = Executors.newCachedThreadPool(); - public AgentChatController(ReActAgent agent, ObjectMapper mapper, + public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, ConversationService conversations, OpService ops) { - this.agent = agent; + this.agentFactory = agentFactory; + this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; @@ -53,6 +64,12 @@ public class AgentChatController { public String text; public String userid; public String conversationId; + // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) + public String authorization; + public String username; + public String brandsid; + public String subsidiaryid; + public String usertype; } @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") @@ -65,6 +82,9 @@ public class AgentChatController { conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); + final AgentIdentity identity = resolveIdentity(req); + final ReActAgent agent = agentFactory.build(identity); + exec.submit(() -> { try { TokenStream ts = agent.chat(convId, userInput); @@ -89,13 +109,30 @@ public class AgentChatController { return emitter; } - /** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */ + /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ + private AgentIdentity resolveIdentity(ChatReq req) { + try { + if (req.authorization != null && !req.authorization.isBlank()) { + return authz.userIdentity(req.authorization.trim(), req.userid, req.username, + req.brandsid, req.subsidiaryid, req.usertype); + } + } catch (Exception e) { + log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage()); + } + return authz.devIdentity(); + } + + /** + * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。 + */ private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { send(emitter, "reset", ""); try { String toolName = te.request() == null ? "" : te.request().name(); - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName) - || "proposeCreate".equals(toolName)) && te.result() != null) { + if (te.result() == null) { + return; + } + if (WRITE_TOOLS.contains(toolName)) { JsonNode r = mapper.readTree(te.result()); String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { @@ -106,9 +143,16 @@ public class AgentChatController { card.put("summary", r.path("summary").asText("")); sendEvent(emitter, card); } + } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { + // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件 + JsonNode r = mapper.readTree(te.result()); + String type = r.path("type").asText(""); + if ("question".equals(type) || "form_collect".equals(type)) { + sendEvent(emitter, mapper.convertValue(r, Map.class)); + } } } catch (Exception e) { - log.warn("handle proposeUpdate result failed", e); + log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); } } diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index 80061a8..6f2ce37 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -48,9 +49,14 @@ public class OpController { return op == null ? Map.of() : op; } - /** 确认执行:调 ERP 更新,回写状态。 */ + /** + * 确认执行:调 ERP 执行暂存的写操作,回写状态。 + *

{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份 + * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。 + */ @PostMapping("/{id}/confirm") - public Map confirm(@PathVariable("id") String id) { + public Map confirm(@PathVariable("id") String id, + @RequestHeader(value = "Authorization", required = false) String authToken) { Map op = ops.get(id); if (op == null) { return result("failed", "找不到该操作", null); @@ -68,11 +74,14 @@ public class OpController { if ("create".equals(opType)) { @SuppressWarnings("unchecked") Map columns = mapper.readValue(str(op.get("sPayload")), Map.class); - r = erp.createForm(str(op.get("sTargetTable")), columns); + r = erp.createForm(authToken, str(op.get("sTargetTable")), columns); } else if ("delete".equals(opType)) { - r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); + r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); + } else if ("examine".equals(opType)) { + int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核 + r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag); } else { - r = erp.updateForm( + r = erp.updateForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), str(op.get("sField")), str(op.get("sNewValue"))); } diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 4571d8e..c5c4e4d 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -711,6 +711,8 @@ $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); } else if (evt.type === "write_proposal") { renderProposalCard(evt.opId, evt.summary); + } else if (evt.type === "form_collect") { + renderFormCollect(evt.entity, evt.fields || []); } else if (evt.type === "error") { if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; @@ -793,6 +795,41 @@ card.find('.op-result').text('已取消'); } + // ====================== FormCollect:对话内动态表单 ====================== + function renderFormCollect(entity, fields) { + hideTypingIndicator(); + const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); + const inputs = (fields || []).map(f => + `

` + + `
` + ).join(''); + const html = ` +
+
+
+
📝 填写「${escapeHtml(entity)}」信息
+ ${inputs} +
+ +
+
+
+
`; + $('#chatMessages').append(html); + $(`#${fid} .fc-submit`).on('click', function () { + const parts = []; + $(`#${fid} .fc-input`).each(function () { + const v = ($(this).val() || '').trim(); + if (v) parts.push($(this).data('label') + '=' + v); + }); + if (parts.length === 0) { alert('请至少填写一个字段'); return; } + $(this).prop('disabled', true).text('已提交'); + $('#messageInput').val('为「' + entity + '」新增,请据此生成新增提议:' + parts.join(',')); + sendMessage(); + }); + scrollToBottom(); + } + // ============================== // 👇 语音排队播放函数(保证顺序) // ============================== -- libgit2 0.22.2