Commit 8d3905a2580b520dce60731d0216bbb1a6414234
1 parent
783cef51
feat: per-request identity (token pass-through §7) + examine write + AskUser + F…
…ormCollect + Skills + KgSearch - AgentIdentity + AgentFactory: build agent per request with token+form-allowlist carried in tool instances (per-call context; robust vs LC4j callback threading) - ErpClient: token-aware read/write/examine; user-token expiry does NOT silently re-login as dev-admin (no privilege escalation) - AuthzService: devIdentity()/userIdentity() resolve granted-form set (sAuthsId) - proposeExamine tool + OpController examine dispatch + forward user Authorization on confirm - InteractionTool.askUser (structured options), FormCollectTool.collectForm (real gdsconfigformslave schema) - SkillService + SkillTool.loadSkill + ai_skill digest in system prompt - KgQueryTool.kgSearch: L2 neighbors/flow + L3 field->table - tools ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool now per-request (not @Component)
Showing
17 changed files
with
884 additions
and
138 deletions
src/main/java/com/xly/agent/AgentIdentity.java
0 → 100644
| 1 | +package com.xly.agent; | ||
| 2 | + | ||
| 3 | +import java.util.Set; | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * 单次 agent 调用的「按调用上下文」身份(架构 §5/§7 的 per-call context)。 | ||
| 7 | + * | ||
| 8 | + * <p>由 {@code AgentChatController} 从前端请求解析(透传的 ERP 登录 token + 稳定身份),随 | ||
| 9 | + * {@link com.xly.config.AgentFactory} 注入到**每请求新建**的工具实例里——因为 LangChain4j 流式 | ||
| 10 | + * 工具执行发生在模型 HTTP 回调线程而非控制器工作线程,ThreadLocal 不可靠,只能把身份放进工具实例本身。 | ||
| 11 | + * | ||
| 12 | + * <p>{@link #token} = 透传的用户 ERP 会话 token(可空 → 退回 dev-login)。{@link #grantedModuleIds} | ||
| 13 | + * = 该用户实际有权的表单/菜单 id 集合({@code null} = 管理员/全部),Read/Query/Write 共用此边界。 | ||
| 14 | + * token 绝不进 prompt / LLM 可见文本。 | ||
| 15 | + */ | ||
| 16 | +public final class AgentIdentity { | ||
| 17 | + | ||
| 18 | + private final String token; | ||
| 19 | + private final String userId; | ||
| 20 | + private final String userNo; | ||
| 21 | + private final String brandsId; | ||
| 22 | + private final String subsidiaryId; | ||
| 23 | + private final String userType; | ||
| 24 | + private final Set<String> grantedModuleIds; // null = 全部(管理员) | ||
| 25 | + | ||
| 26 | + public AgentIdentity(String token, String userId, String userNo, String brandsId, | ||
| 27 | + String subsidiaryId, String userType, Set<String> grantedModuleIds) { | ||
| 28 | + this.token = token; | ||
| 29 | + this.userId = userId; | ||
| 30 | + this.userNo = userNo; | ||
| 31 | + this.brandsId = brandsId; | ||
| 32 | + this.subsidiaryId = subsidiaryId; | ||
| 33 | + this.userType = userType; | ||
| 34 | + this.grantedModuleIds = grantedModuleIds; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /** 透传的用户 token;为空表示回退到 dev-login(本地开发)。 */ | ||
| 38 | + public String token() { | ||
| 39 | + return token; | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + public boolean hasUserToken() { | ||
| 43 | + return token != null && !token.isBlank(); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + public String userId() { | ||
| 47 | + return userId; | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + public String userNo() { | ||
| 51 | + return userNo; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + public String brandsId() { | ||
| 55 | + return brandsId; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + public String subsidiaryId() { | ||
| 59 | + return subsidiaryId; | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + public String userType() { | ||
| 63 | + return userType; | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + /** null = 全部权限(管理员)。 */ | ||
| 67 | + public Set<String> grantedModuleIds() { | ||
| 68 | + return grantedModuleIds; | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + public boolean isAdminAll() { | ||
| 72 | + return grantedModuleIds == null; | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + public boolean canAccessModule(String moduleId) { | ||
| 76 | + return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId)); | ||
| 77 | + } | ||
| 78 | +} |
src/main/java/com/xly/config/AgentConfig.java
| 1 | package com.xly.config; | 1 | package com.xly.config; |
| 2 | 2 | ||
| 3 | -import com.xly.agent.ReActAgent; | ||
| 4 | -import com.xly.service.SystemPromptService; | ||
| 5 | -import com.xly.tool.ErpReadTool; | ||
| 6 | -import com.xly.tool.KgQueryTool; | ||
| 7 | -import com.xly.tool.ProposeWriteTool; | ||
| 8 | -import com.xly.tool.QueryTool; | ||
| 9 | -import dev.langchain4j.memory.chat.MessageWindowChatMemory; | ||
| 10 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; | 3 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; |
| 11 | -import dev.langchain4j.service.AiServices; | 4 | +import org.springframework.beans.factory.annotation.Autowired; |
| 12 | import org.springframework.beans.factory.annotation.Value; | 5 | import org.springframework.beans.factory.annotation.Value; |
| 13 | import org.springframework.context.annotation.Bean; | 6 | import org.springframework.context.annotation.Bean; |
| 14 | import org.springframework.context.annotation.Configuration; | 7 | import org.springframework.context.annotation.Configuration; |
| 15 | 8 | ||
| 16 | import java.time.Duration; | 9 | import java.time.Duration; |
| 10 | +import java.util.List; | ||
| 17 | 11 | ||
| 18 | /** | 12 | /** |
| 19 | - * 组装单一 ReAct agent(M1)。 | 13 | + * agent 用的流式模型 Bean。 |
| 20 | * | 14 | * |
| 21 | - * <p>= 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆 | ||
| 22 | - * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。 | 15 | + * <p>单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例, |
| 16 | + * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。 | ||
| 23 | */ | 17 | */ |
| 24 | @Configuration | 18 | @Configuration |
| 25 | public class AgentConfig { | 19 | public class AgentConfig { |
| @@ -30,7 +24,7 @@ public class AgentConfig { | @@ -30,7 +24,7 @@ public class AgentConfig { | ||
| 30 | @Value("${langchain4j.ollama.chat-model-name}") | 24 | @Value("${langchain4j.ollama.chat-model-name}") |
| 31 | private String chatModelName; | 25 | private String chatModelName; |
| 32 | 26 | ||
| 33 | - @org.springframework.beans.factory.annotation.Autowired | 27 | + @Autowired |
| 34 | private TracingChatModelListener tracingListener; | 28 | private TracingChatModelListener tracingListener; |
| 35 | 29 | ||
| 36 | /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ | 30 | /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ |
| @@ -45,28 +39,8 @@ public class AgentConfig { | @@ -45,28 +39,8 @@ public class AgentConfig { | ||
| 45 | // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 | 39 | // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 |
| 46 | .think(false) | 40 | .think(false) |
| 47 | .returnThinking(false) | 41 | .returnThinking(false) |
| 48 | - .listeners(java.util.List.of(tracingListener)) | 42 | + .listeners(List.of(tracingListener)) |
| 49 | .timeout(Duration.ofSeconds(180)) | 43 | .timeout(Duration.ofSeconds(180)) |
| 50 | .build(); | 44 | .build(); |
| 51 | } | 45 | } |
| 52 | - | ||
| 53 | - @Bean | ||
| 54 | - public ReActAgent reActAgent(SystemPromptService systemPromptService, | ||
| 55 | - KgQueryTool kgQueryTool, | ||
| 56 | - ErpReadTool erpReadTool, | ||
| 57 | - ProposeWriteTool proposeWriteTool, | ||
| 58 | - QueryTool queryTool, | ||
| 59 | - RedisChatMemoryStore memoryStore) { | ||
| 60 | - String systemPrompt = systemPromptService.buildSystemPrompt(); | ||
| 61 | - return AiServices.builder(ReActAgent.class) | ||
| 62 | - .streamingChatModel(agentStreamingModel()) | ||
| 63 | - .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool) | ||
| 64 | - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | ||
| 65 | - .id(memoryId) | ||
| 66 | - .maxMessages(30) | ||
| 67 | - .chatMemoryStore(memoryStore) | ||
| 68 | - .build()) | ||
| 69 | - .systemMessageProvider(memoryId -> systemPrompt) | ||
| 70 | - .build(); | ||
| 71 | - } | ||
| 72 | } | 46 | } |
src/main/java/com/xly/config/AgentFactory.java
0 → 100644
| 1 | +package com.xly.config; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.agent.AgentIdentity; | ||
| 5 | +import com.xly.agent.ReActAgent; | ||
| 6 | +import com.xly.service.AuditService; | ||
| 7 | +import com.xly.service.ErpClient; | ||
| 8 | +import com.xly.service.FormResolverService; | ||
| 9 | +import com.xly.service.OpService; | ||
| 10 | +import com.xly.service.SkillService; | ||
| 11 | +import com.xly.service.SystemPromptService; | ||
| 12 | +import com.xly.tool.ErpReadTool; | ||
| 13 | +import com.xly.tool.FormCollectTool; | ||
| 14 | +import com.xly.tool.InteractionTool; | ||
| 15 | +import com.xly.tool.KgQueryTool; | ||
| 16 | +import com.xly.tool.ProposeWriteTool; | ||
| 17 | +import com.xly.tool.QueryTool; | ||
| 18 | +import com.xly.tool.SkillTool; | ||
| 19 | +import dev.langchain4j.memory.chat.MessageWindowChatMemory; | ||
| 20 | +import dev.langchain4j.model.ollama.OllamaChatModel; | ||
| 21 | +import dev.langchain4j.model.ollama.OllamaStreamingChatModel; | ||
| 22 | +import dev.langchain4j.service.AiServices; | ||
| 23 | +import org.springframework.beans.factory.annotation.Qualifier; | ||
| 24 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 25 | +import org.springframework.stereotype.Component; | ||
| 26 | + | ||
| 27 | +/** | ||
| 28 | + * 按「每次请求的身份」组装单一 ReAct agent(架构 §5/§7 的 per-call context)。 | ||
| 29 | + * | ||
| 30 | + * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, | ||
| 31 | + * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 | ||
| 32 | + * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。 | ||
| 33 | + * | ||
| 34 | + * <p>无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、 | ||
| 35 | + * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。 | ||
| 36 | + */ | ||
| 37 | +@Component | ||
| 38 | +public class AgentFactory { | ||
| 39 | + | ||
| 40 | + private final OllamaStreamingChatModel streamingModel; | ||
| 41 | + private final OllamaChatModel sqlModel; | ||
| 42 | + private final RedisChatMemoryStore memoryStore; | ||
| 43 | + private final SystemPromptService systemPromptService; | ||
| 44 | + | ||
| 45 | + private final ErpClient erp; | ||
| 46 | + private final JdbcTemplate jdbc; | ||
| 47 | + private final FormResolverService resolver; | ||
| 48 | + private final OpService ops; | ||
| 49 | + private final ObjectMapper mapper; | ||
| 50 | + private final AuditService audit; | ||
| 51 | + private final SkillService skillService; | ||
| 52 | + | ||
| 53 | + private final KgQueryTool kgQueryTool; | ||
| 54 | + private final SkillTool skillTool; | ||
| 55 | + private final InteractionTool interactionTool; | ||
| 56 | + | ||
| 57 | + private volatile String cachedSystemPrompt; | ||
| 58 | + | ||
| 59 | + public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel, | ||
| 60 | + @Qualifier("sqlChatModel") OllamaChatModel sqlModel, | ||
| 61 | + RedisChatMemoryStore memoryStore, | ||
| 62 | + SystemPromptService systemPromptService, | ||
| 63 | + ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, | ||
| 64 | + ObjectMapper mapper, AuditService audit, SkillService skillService, | ||
| 65 | + KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) { | ||
| 66 | + this.streamingModel = streamingModel; | ||
| 67 | + this.sqlModel = sqlModel; | ||
| 68 | + this.memoryStore = memoryStore; | ||
| 69 | + this.systemPromptService = systemPromptService; | ||
| 70 | + this.erp = erp; | ||
| 71 | + this.jdbc = jdbc; | ||
| 72 | + this.resolver = resolver; | ||
| 73 | + this.ops = ops; | ||
| 74 | + this.mapper = mapper; | ||
| 75 | + this.audit = audit; | ||
| 76 | + this.skillService = skillService; | ||
| 77 | + this.kgQueryTool = kgQueryTool; | ||
| 78 | + this.skillTool = skillTool; | ||
| 79 | + this.interactionTool = interactionTool; | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */ | ||
| 83 | + private String systemPrompt() { | ||
| 84 | + String p = cachedSystemPrompt; | ||
| 85 | + if (p == null) { | ||
| 86 | + synchronized (this) { | ||
| 87 | + if (cachedSystemPrompt == null) { | ||
| 88 | + cachedSystemPrompt = systemPromptService.buildSystemPrompt(); | ||
| 89 | + } | ||
| 90 | + p = cachedSystemPrompt; | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + return p; | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */ | ||
| 97 | + public ReActAgent build(AgentIdentity identity) { | ||
| 98 | + String systemPrompt = systemPrompt(); | ||
| 99 | + return AiServices.builder(ReActAgent.class) | ||
| 100 | + .streamingChatModel(streamingModel) | ||
| 101 | + .tools( | ||
| 102 | + kgQueryTool, | ||
| 103 | + skillTool, | ||
| 104 | + interactionTool, | ||
| 105 | + new ErpReadTool(erp, jdbc, resolver, identity), | ||
| 106 | + new ProposeWriteTool(erp, jdbc, ops, mapper, identity), | ||
| 107 | + new QueryTool(sqlModel, jdbc, audit, identity), | ||
| 108 | + new FormCollectTool(erp, jdbc, resolver, identity, mapper)) | ||
| 109 | + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | ||
| 110 | + .id(memoryId) | ||
| 111 | + .maxMessages(30) | ||
| 112 | + .chatMemoryStore(memoryStore) | ||
| 113 | + .build()) | ||
| 114 | + .systemMessageProvider(memoryId -> systemPrompt) | ||
| 115 | + .build(); | ||
| 116 | + } | ||
| 117 | +} |
src/main/java/com/xly/service/AuthzService.java
| @@ -36,6 +36,9 @@ public class AuthzService { | @@ -36,6 +36,9 @@ public class AuthzService { | ||
| 36 | @Value("${erp.dev-login.usertype:sysadmin}") | 36 | @Value("${erp.dev-login.usertype:sysadmin}") |
| 37 | private String devUserType; | 37 | private String devUserType; |
| 38 | 38 | ||
| 39 | + @Value("${erp.dev-login.userid:}") | ||
| 40 | + private String devUserIdOverride; | ||
| 41 | + | ||
| 39 | public AuthzService(JdbcTemplate jdbc) { | 42 | public AuthzService(JdbcTemplate jdbc) { |
| 40 | this.jdbc = jdbc; | 43 | this.jdbc = jdbc; |
| 41 | } | 44 | } |
| @@ -46,6 +49,26 @@ public class AuthzService { | @@ -46,6 +49,26 @@ public class AuthzService { | ||
| 46 | return isAllowed(granted, moduleId); | 49 | return isAllowed(granted, moduleId); |
| 47 | } | 50 | } |
| 48 | 51 | ||
| 52 | + /** | ||
| 53 | + * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析 | ||
| 54 | + * (admin → null = 全部)。 | ||
| 55 | + */ | ||
| 56 | + public com.xly.agent.AgentIdentity devIdentity() { | ||
| 57 | + String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); | ||
| 58 | + Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub); | ||
| 59 | + return new com.xly.agent.AgentIdentity(null, uid, devUserNo, devBrand, devSub, devUserType, granted); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + /** | ||
| 63 | + * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户 | ||
| 64 | + * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。 | ||
| 65 | + */ | ||
| 66 | + public com.xly.agent.AgentIdentity userIdentity(String token, String userId, String userNo, | ||
| 67 | + String brandsId, String subsidiaryId, String userType) { | ||
| 68 | + Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId); | ||
| 69 | + return new com.xly.agent.AgentIdentity(token, userId, userNo, brandsId, subsidiaryId, userType, granted); | ||
| 70 | + } | ||
| 71 | + | ||
| 49 | /** null = 全部(管理员);否则 = 有权的 id 集合。 */ | 72 | /** null = 全部(管理员);否则 = 有权的 id 集合。 */ |
| 50 | public Set<String> grantedIds(String userId, String userType, String brandsId, String subsidiaryId) { | 73 | public Set<String> grantedIds(String userId, String userType, String brandsId, String subsidiaryId) { |
| 51 | if (isAdmin(userType)) { | 74 | if (isAdmin(userType)) { |
src/main/java/com/xly/service/ErpClient.java
| @@ -85,16 +85,38 @@ public class ErpClient { | @@ -85,16 +85,38 @@ public class ErpClient { | ||
| 85 | return (t != null && !t.isBlank()) ? t : login(); | 85 | return (t != null && !t.isBlank()) ? t : login(); |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | + /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */ | ||
| 89 | + private String resolveToken(String override) { | ||
| 90 | + return (override != null && !override.isBlank()) ? override : token(); | ||
| 91 | + } | ||
| 92 | + | ||
| 88 | /** | 93 | /** |
| 89 | - * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 | ||
| 90 | - * 会话过期(code=-2)时自动重登一次并重试。 | 94 | + * 是否允许在 code=-2(会话过期)时重登重试。 |
| 95 | + * <p><b>安全</b>:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时 | ||
| 96 | + * <b>绝不</b>用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回, | ||
| 97 | + * 由上层向对话推「登录过期」。 | ||
| 91 | */ | 98 | */ |
| 99 | + private boolean canRelogin(String override) { | ||
| 100 | + return override == null || override.isBlank(); | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */ | ||
| 92 | public JsonNode readForm(String formId, String moduleId, int page, int pageSize, | 104 | public JsonNode readForm(String formId, String moduleId, int page, int pageSize, |
| 93 | String filterField, String filterValue) { | 105 | String filterField, String filterValue) { |
| 94 | - JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); | ||
| 95 | - if (root.path("code").asInt() == -2) { | 106 | + return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue); |
| 107 | + } | ||
| 108 | + | ||
| 109 | + /** | ||
| 110 | + * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 | ||
| 111 | + * {@code authToken} 为透传的用户 token(可空 → dev-login)。dev-login 会话过期(code=-2)时自动重登重试; | ||
| 112 | + * 用户 token 过期不重登(见 {@link #canRelogin})。 | ||
| 113 | + */ | ||
| 114 | + public JsonNode readForm(String authToken, String formId, String moduleId, int page, int pageSize, | ||
| 115 | + String filterField, String filterValue) { | ||
| 116 | + JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken)); | ||
| 117 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | ||
| 96 | login(); | 118 | login(); |
| 97 | - root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); | 119 | + root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken)); |
| 98 | } | 120 | } |
| 99 | return root; | 121 | return root; |
| 100 | } | 122 | } |
| @@ -132,24 +154,67 @@ public class ErpClient { | @@ -132,24 +154,67 @@ public class ErpClient { | ||
| 132 | * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 | 154 | * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 |
| 133 | */ | 155 | */ |
| 134 | public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { | 156 | public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { |
| 135 | - JsonNode root = doUpdate(moduleId, table, billId, field, value, token()); | ||
| 136 | - if (root.path("code").asInt() == -2) { | 157 | + return updateForm(null, moduleId, table, billId, field, value); |
| 158 | + } | ||
| 159 | + | ||
| 160 | + public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) { | ||
| 161 | + JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); | ||
| 162 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | ||
| 137 | login(); | 163 | login(); |
| 138 | - root = doUpdate(moduleId, table, billId, field, value, token()); | 164 | + root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); |
| 139 | } | 165 | } |
| 140 | return root; | 166 | return root; |
| 141 | } | 167 | } |
| 142 | 168 | ||
| 143 | /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ | 169 | /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ |
| 144 | public JsonNode deleteForm(String moduleId, String table, String billId) { | 170 | public JsonNode deleteForm(String moduleId, String table, String billId) { |
| 145 | - JsonNode root = doDelete(moduleId, table, billId, token()); | ||
| 146 | - if (root.path("code").asInt() == -2) { | 171 | + return deleteForm(null, moduleId, table, billId); |
| 172 | + } | ||
| 173 | + | ||
| 174 | + public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) { | ||
| 175 | + JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken)); | ||
| 176 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | ||
| 177 | + login(); | ||
| 178 | + root = doDelete(moduleId, table, billId, resolveToken(authToken)); | ||
| 179 | + } | ||
| 180 | + return root; | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + /** | ||
| 184 | + * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。 | ||
| 185 | + * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。 | ||
| 186 | + */ | ||
| 187 | + public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) { | ||
| 188 | + JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); | ||
| 189 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | ||
| 147 | login(); | 190 | login(); |
| 148 | - root = doDelete(moduleId, table, billId, token()); | 191 | + root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); |
| 149 | } | 192 | } |
| 150 | return root; | 193 | return root; |
| 151 | } | 194 | } |
| 152 | 195 | ||
| 196 | + private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) { | ||
| 197 | + try { | ||
| 198 | + String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId; | ||
| 199 | + Map<String, Object> paramsMap = new LinkedHashMap<>(); | ||
| 200 | + paramsMap.put("sFormGuid", moduleId); | ||
| 201 | + paramsMap.put("sGuid", billId); | ||
| 202 | + paramsMap.put("iFlag", iFlag); | ||
| 203 | + paramsMap.put("sSlaveId", ""); | ||
| 204 | + String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap)); | ||
| 205 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | ||
| 206 | + .header("Content-Type", "application/json;charset=UTF-8") | ||
| 207 | + .header("Authorization", tok) | ||
| 208 | + .timeout(Duration.ofSeconds(60)) | ||
| 209 | + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) | ||
| 210 | + .build(); | ||
| 211 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | ||
| 212 | + return mapper.readTree(resp.body()); | ||
| 213 | + } catch (Exception e) { | ||
| 214 | + throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e); | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + | ||
| 153 | private JsonNode doDelete(String moduleId, String table, String billId, String tok) { | 218 | private JsonNode doDelete(String moduleId, String table, String billId, String tok) { |
| 154 | try { | 219 | try { |
| 155 | String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; | 220 | String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; |
| @@ -176,9 +241,13 @@ public class ErpClient { | @@ -176,9 +241,13 @@ public class ErpClient { | ||
| 176 | 241 | ||
| 177 | /** 取一个新主键 uuid(ERP `/getUuid`)。 */ | 242 | /** 取一个新主键 uuid(ERP `/getUuid`)。 */ |
| 178 | public String newUuid() { | 243 | public String newUuid() { |
| 244 | + return newUuid(null); | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + public String newUuid(String authToken) { | ||
| 179 | try { | 248 | try { |
| 180 | HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) | 249 | HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) |
| 181 | - .header("Authorization", token()) | 250 | + .header("Authorization", resolveToken(authToken)) |
| 182 | .timeout(Duration.ofSeconds(15)) | 251 | .timeout(Duration.ofSeconds(15)) |
| 183 | .GET() | 252 | .GET() |
| 184 | .build(); | 253 | .build(); |
| @@ -192,10 +261,14 @@ public class ErpClient { | @@ -192,10 +261,14 @@ public class ErpClient { | ||
| 192 | 261 | ||
| 193 | /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ | 262 | /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ |
| 194 | public JsonNode createForm(String table, Map<String, Object> columns) { | 263 | public JsonNode createForm(String table, Map<String, Object> columns) { |
| 195 | - JsonNode root = doCreate(table, columns, token()); | ||
| 196 | - if (root.path("code").asInt() == -2) { | 264 | + return createForm(null, table, columns); |
| 265 | + } | ||
| 266 | + | ||
| 267 | + public JsonNode createForm(String authToken, String table, Map<String, Object> columns) { | ||
| 268 | + JsonNode root = doCreate(table, columns, resolveToken(authToken)); | ||
| 269 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | ||
| 197 | login(); | 270 | login(); |
| 198 | - root = doCreate(table, columns, token()); | 271 | + root = doCreate(table, columns, resolveToken(authToken)); |
| 199 | } | 272 | } |
| 200 | return root; | 273 | return root; |
| 201 | } | 274 | } |
src/main/java/com/xly/service/SkillService.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 4 | +import org.springframework.stereotype.Service; | ||
| 5 | + | ||
| 6 | +import java.util.List; | ||
| 7 | +import java.util.Map; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * Skill 注册表服务(架构 §6)。 | ||
| 11 | + * | ||
| 12 | + * <p>Skill = 针对重复任务的 playbook(新建报价 / 月度对账 / 库存盘点)。渐进披露: | ||
| 13 | + * {@link #promptDigest()} 把「name + 何时用」摘要注入 system prompt(便宜、常驻); | ||
| 14 | + * {@link #load(String)} 在 {@code load_skill} 工具被调用时返回完整指令。数据在 {@code ai_skill} 表。 | ||
| 15 | + */ | ||
| 16 | +@Service | ||
| 17 | +public class SkillService { | ||
| 18 | + | ||
| 19 | + private final JdbcTemplate jdbc; | ||
| 20 | + | ||
| 21 | + public SkillService(JdbcTemplate jdbc) { | ||
| 22 | + this.jdbc = jdbc; | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + /** 供 system prompt 的 Skill 摘要(name + 何时用),一行一个。KG/表缺失时降级为空串。 */ | ||
| 26 | + public String promptDigest() { | ||
| 27 | + try { | ||
| 28 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 29 | + "SELECT sName, sWhenToUse FROM ai_skill WHERE bEnabled=1 ORDER BY iOrder"); | ||
| 30 | + if (rows.isEmpty()) { | ||
| 31 | + return ""; | ||
| 32 | + } | ||
| 33 | + StringBuilder sb = new StringBuilder(); | ||
| 34 | + for (Map<String, Object> r : rows) { | ||
| 35 | + sb.append("- ").append(r.get("sName")).append(":").append(r.get("sWhenToUse")).append("\n"); | ||
| 36 | + } | ||
| 37 | + return sb.toString(); | ||
| 38 | + } catch (Exception e) { | ||
| 39 | + return ""; | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /** 按名称加载完整 playbook(精确优先,模糊兜底)。返回 null = 找不到。 */ | ||
| 44 | + public Map<String, Object> load(String name) { | ||
| 45 | + if (name == null || name.isBlank()) { | ||
| 46 | + return null; | ||
| 47 | + } | ||
| 48 | + List<Map<String, Object>> r = jdbc.queryForList( | ||
| 49 | + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName=? LIMIT 1", name.trim()); | ||
| 50 | + if (r.isEmpty()) { | ||
| 51 | + r = jdbc.queryForList( | ||
| 52 | + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName LIKE ? ORDER BY iOrder LIMIT 1", | ||
| 53 | + "%" + name.trim() + "%"); | ||
| 54 | + } | ||
| 55 | + return r.isEmpty() ? null : r.get(0); | ||
| 56 | + } | ||
| 57 | +} |
src/main/java/com/xly/service/SystemPromptService.java
| @@ -9,17 +9,20 @@ import java.util.Map; | @@ -9,17 +9,20 @@ import java.util.Map; | ||
| 9 | /** | 9 | /** |
| 10 | * 构建单 agent 的 system prompt。 | 10 | * 构建单 agent 的 system prompt。 |
| 11 | * | 11 | * |
| 12 | - * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)渲染进 | ||
| 13 | - * system prompt,作为常驻的「路由地图」——让单 agent 先判断问题属于哪个业务域、涉及哪些单据, | ||
| 14 | - * 再决定调用哪个工具。L1 体量小且永远相关,适合常驻 prompt;L2/L3 大而稀疏,走工具按需查。 | 12 | + * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)与 |
| 13 | + * Skill 摘要({@code ai_skill} 的 name+何时用)渲染进 system prompt,作为常驻的「路由地图 + 技能目录」—— | ||
| 14 | + * 让单 agent 先判断问题属于哪个业务域/是否命中某 Skill,再决定调哪个工具。L1/Skill 摘要体量小且永远相关, | ||
| 15 | + * 适合常驻;L2/L3 与 Skill 详情大而稀疏,走工具(KgSearch / load_skill)按需查。 | ||
| 15 | */ | 16 | */ |
| 16 | @Service | 17 | @Service |
| 17 | public class SystemPromptService { | 18 | public class SystemPromptService { |
| 18 | 19 | ||
| 19 | private final JdbcTemplate jdbc; | 20 | private final JdbcTemplate jdbc; |
| 21 | + private final SkillService skills; | ||
| 20 | 22 | ||
| 21 | - public SystemPromptService(JdbcTemplate jdbc) { | 23 | + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { |
| 22 | this.jdbc = jdbc; | 24 | this.jdbc = jdbc; |
| 25 | + this.skills = skills; | ||
| 23 | } | 26 | } |
| 24 | 27 | ||
| 25 | public String buildSystemPrompt() { | 28 | public String buildSystemPrompt() { |
| @@ -27,24 +30,31 @@ public class SystemPromptService { | @@ -27,24 +30,31 @@ public class SystemPromptService { | ||
| 27 | 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 | 30 | 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 |
| 28 | 31 | ||
| 29 | 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ | 32 | 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ |
| 30 | - 帮助他们查询和(未来)操作 ERP 里的业务单据。 | 33 | + 帮助他们查询和操作 ERP 里的业务单据。 |
| 31 | 34 | ||
| 32 | 【业务域地图(L1 路由)】 | 35 | 【业务域地图(L1 路由)】 |
| 33 | 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ | 36 | 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ |
| 34 | 可能涉及哪些单据,再决定怎么做: | 37 | 可能涉及哪些单据,再决定怎么做: |
| 35 | %s | 38 | %s |
| 39 | + 【可用技能 Skills】 | ||
| 40 | + 下面是一些常见任务的技能。若用户需求命中某个技能的「何时用」,先调用 loadSkill(该技能名) 拿到详细步骤再照做: | ||
| 41 | + %s | ||
| 36 | 【可用工具】 | 42 | 【可用工具】 |
| 37 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 | 43 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 44 | + - kgSearch(keyword):查知识图谱——某表单的上下游流转、相邻单据、字段所在的表/列。想弄清「这张单从哪来、到哪去」或「某字段在哪张表」时用。 | ||
| 38 | - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 | 45 | - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 |
| 39 | - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ | 46 | - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ |
| 40 | 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 | 47 | 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 |
| 41 | - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ | 48 | - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ |
| 42 | 仅当 readFormData / lookupRecord 都答不了时才用。 | 49 | 仅当 readFormData / lookupRecord 都答不了时才用。 |
| 43 | - - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ | ||
| 44 | - (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 | ||
| 45 | - - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 | 50 | + - askUser(question, options?):意图不明或需二选一时,向用户提一个带选项的小问题;问完即等用户回答。 |
| 51 | + - collectForm(entityKeyword):新增字段较多的单据(如报价)时,弹一张表单让用户一次填齐;提交后你再用 proposeCreate。 | ||
| 46 | - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ | 52 | - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ |
| 47 | fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 | 53 | fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 |
| 54 | + - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段(写操作,只提议、暂不执行)。 | ||
| 55 | + - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行)。删除不可恢复,慎用。 | ||
| 56 | + - proposeExamine(entityKeyword, recordKeyword):**提议审核/过账**某张单据(写操作,只提议、暂不执行)。审核有业务后果,慎用。 | ||
| 57 | + - loadSkill(name):加载某个业务技能的完整操作指南(见上方 Skills 清单)。 | ||
| 48 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ | 58 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
| 49 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ | 59 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ |
| 50 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 | 60 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 |
| @@ -53,11 +63,16 @@ public class SystemPromptService { | @@ -53,11 +63,16 @@ public class SystemPromptService { | ||
| 53 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | 63 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 |
| 54 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 | 64 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 |
| 55 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 | 65 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 56 | - 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ | ||
| 57 | - 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 | 66 | + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete、审核用 proposeExamine。\ |
| 67 | + 它们都**只生成待确认提议、不立即执行**;真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。 | ||
| 58 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ | 68 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ |
| 59 | - 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 | ||
| 60 | - """.formatted(renderDomainMap()); | 69 | + 只有确实缺少关键参数(如不知道要查哪张单据)时才用 askUser 提问。 |
| 70 | + """.formatted(renderDomainMap(), renderSkills()); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + private String renderSkills() { | ||
| 74 | + String digest = skills.promptDigest(); | ||
| 75 | + return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest; | ||
| 61 | } | 76 | } |
| 62 | 77 | ||
| 63 | private String renderDomainMap() { | 78 | private String renderDomainMap() { |
src/main/java/com/xly/tool/ErpReadTool.java
| 1 | package com.xly.tool; | 1 | package com.xly.tool; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | -import com.xly.service.AuthzService; | 4 | +import com.xly.agent.AgentIdentity; |
| 5 | import com.xly.service.ErpClient; | 5 | import com.xly.service.ErpClient; |
| 6 | import com.xly.service.FormResolverService; | 6 | import com.xly.service.FormResolverService; |
| 7 | import dev.langchain4j.agent.tool.P; | 7 | import dev.langchain4j.agent.tool.P; |
| 8 | import dev.langchain4j.agent.tool.Tool; | 8 | import dev.langchain4j.agent.tool.Tool; |
| 9 | import org.springframework.jdbc.core.JdbcTemplate; | 9 | import org.springframework.jdbc.core.JdbcTemplate; |
| 10 | -import org.springframework.stereotype.Component; | ||
| 11 | 10 | ||
| 12 | import java.util.ArrayList; | 11 | import java.util.ArrayList; |
| 13 | import java.util.HashMap; | 12 | import java.util.HashMap; |
| @@ -25,7 +24,6 @@ import java.util.stream.Collectors; | @@ -25,7 +24,6 @@ import java.util.stream.Collectors; | ||
| 25 | * | 24 | * |
| 26 | * <p>安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 | 25 | * <p>安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 |
| 27 | */ | 26 | */ |
| 28 | -@Component | ||
| 29 | public class ErpReadTool { | 27 | public class ErpReadTool { |
| 30 | 28 | ||
| 31 | private static final int MAX_ROWS = 10; | 29 | private static final int MAX_ROWS = 10; |
| @@ -34,13 +32,13 @@ public class ErpReadTool { | @@ -34,13 +32,13 @@ public class ErpReadTool { | ||
| 34 | private final ErpClient erp; | 32 | private final ErpClient erp; |
| 35 | private final JdbcTemplate jdbc; | 33 | private final JdbcTemplate jdbc; |
| 36 | private final FormResolverService resolver; | 34 | private final FormResolverService resolver; |
| 37 | - private final AuthzService authz; | 35 | + private final AgentIdentity identity; |
| 38 | 36 | ||
| 39 | - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AuthzService authz) { | 37 | + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity) { |
| 40 | this.erp = erp; | 38 | this.erp = erp; |
| 41 | this.jdbc = jdbc; | 39 | this.jdbc = jdbc; |
| 42 | this.resolver = resolver; | 40 | this.resolver = resolver; |
| 43 | - this.authz = authz; | 41 | + this.identity = identity; |
| 44 | } | 42 | } |
| 45 | 43 | ||
| 46 | @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" | 44 | @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" |
| @@ -58,14 +56,14 @@ public class ErpReadTool { | @@ -58,14 +56,14 @@ public class ErpReadTool { | ||
| 58 | String formId = String.valueOf(form.get("sFormId")); | 56 | String formId = String.valueOf(form.get("sFormId")); |
| 59 | String moduleId = String.valueOf(form.get("sModuleId")); | 57 | String moduleId = String.valueOf(form.get("sModuleId")); |
| 60 | String table = String.valueOf(form.get("sDataSource")); | 58 | String table = String.valueOf(form.get("sDataSource")); |
| 61 | - if (!authz.canAccessModule(moduleId)) { | 59 | + if (!identity.canAccessModule(moduleId)) { |
| 62 | return "你没有访问「" + entityKeyword + "」的权限。"; | 60 | return "你没有访问「" + entityKeyword + "」的权限。"; |
| 63 | } | 61 | } |
| 64 | String nameField = resolver.resolveNameField(table); | 62 | String nameField = resolver.resolveNameField(table); |
| 65 | 63 | ||
| 66 | JsonNode root; | 64 | JsonNode root; |
| 67 | try { | 65 | try { |
| 68 | - root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim()); | 66 | + root = erp.readForm(identity.token(), formId, moduleId, 1, 3, nameField, recordKeyword.trim()); |
| 69 | } catch (Exception e) { | 67 | } catch (Exception e) { |
| 70 | return "读取失败:" + e.getMessage(); | 68 | return "读取失败:" + e.getMessage(); |
| 71 | } | 69 | } |
| @@ -125,7 +123,7 @@ public class ErpReadTool { | @@ -125,7 +123,7 @@ public class ErpReadTool { | ||
| 125 | if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { | 123 | if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { |
| 126 | return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; | 124 | return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; |
| 127 | } | 125 | } |
| 128 | - if (!authz.canAccessModule(moduleId.trim())) { | 126 | + if (!identity.canAccessModule(moduleId.trim())) { |
| 129 | return "你没有访问该表单的权限。"; | 127 | return "你没有访问该表单的权限。"; |
| 130 | } | 128 | } |
| 131 | String kw = (keyword == null) ? "" : keyword.trim(); | 129 | String kw = (keyword == null) ? "" : keyword.trim(); |
| @@ -133,7 +131,7 @@ public class ErpReadTool { | @@ -133,7 +131,7 @@ public class ErpReadTool { | ||
| 133 | 131 | ||
| 134 | JsonNode root; | 132 | JsonNode root; |
| 135 | try { | 133 | try { |
| 136 | - root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); | 134 | + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); |
| 137 | } catch (Exception e) { | 135 | } catch (Exception e) { |
| 138 | return "读取失败:" + e.getMessage(); | 136 | return "读取失败:" + e.getMessage(); |
| 139 | } | 137 | } |
src/main/java/com/xly/tool/FormCollectTool.java
| 1 | package com.xly.tool; | 1 | package com.xly.tool; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper; | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | +import com.xly.agent.AgentIdentity; | ||
| 5 | +import com.xly.service.ErpClient; | ||
| 4 | import com.xly.service.FormResolverService; | 6 | import com.xly.service.FormResolverService; |
| 5 | import dev.langchain4j.agent.tool.P; | 7 | import dev.langchain4j.agent.tool.P; |
| 6 | import dev.langchain4j.agent.tool.Tool; | 8 | import dev.langchain4j.agent.tool.Tool; |
| 7 | import org.springframework.jdbc.core.JdbcTemplate; | 9 | import org.springframework.jdbc.core.JdbcTemplate; |
| 8 | -import org.springframework.stereotype.Component; | ||
| 9 | 10 | ||
| 10 | import java.util.ArrayList; | 11 | import java.util.ArrayList; |
| 11 | -import java.util.HashSet; | ||
| 12 | import java.util.LinkedHashMap; | 12 | import java.util.LinkedHashMap; |
| 13 | import java.util.List; | 13 | import java.util.List; |
| 14 | import java.util.Map; | 14 | import java.util.Map; |
| 15 | -import java.util.Set; | ||
| 16 | 15 | ||
| 17 | /** | 16 | /** |
| 18 | - * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 | 17 | + * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, |
| 18 | + * 而不是逐字段追问。字段/类型/必填/下拉/默认来自 ERP 表单元数据({@code gdsconfigformslave})。 | ||
| 19 | * | 19 | * |
| 20 | - * <p>字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段), | ||
| 21 | - * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走 | ||
| 22 | - * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 | 20 | + * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 |
| 21 | + * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回, | ||
| 22 | + * agent 再据此走 {@code proposeCreate}(人在环写入)。适合报价这类字段多的新建场景。 | ||
| 23 | + * | ||
| 24 | + * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 | ||
| 23 | */ | 25 | */ |
| 24 | -@Component | ||
| 25 | public class FormCollectTool { | 26 | public class FormCollectTool { |
| 26 | 27 | ||
| 27 | - private final FormResolverService resolver; | 28 | + private static final int MAX_FIELDS = 40; |
| 29 | + | ||
| 30 | + private final ErpClient erp; | ||
| 28 | private final JdbcTemplate jdbc; | 31 | private final JdbcTemplate jdbc; |
| 32 | + private final FormResolverService resolver; | ||
| 33 | + private final AgentIdentity identity; | ||
| 29 | private final ObjectMapper mapper; | 34 | private final ObjectMapper mapper; |
| 30 | 35 | ||
| 31 | - public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) { | ||
| 32 | - this.resolver = resolver; | 36 | + public FormCollectTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, |
| 37 | + AgentIdentity identity, ObjectMapper mapper) { | ||
| 38 | + this.erp = erp; | ||
| 33 | this.jdbc = jdbc; | 39 | this.jdbc = jdbc; |
| 40 | + this.resolver = resolver; | ||
| 41 | + this.identity = identity; | ||
| 34 | this.mapper = mapper; | 42 | this.mapper = mapper; |
| 35 | } | 43 | } |
| 36 | 44 | ||
| 37 | - @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。" | ||
| 38 | - + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。") | ||
| 39 | - public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { | 45 | + @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问)。用于字段较多的新建/录入场景" |
| 46 | + + "(如新建报价、新建客户)。入参 = 实体/单据类型(如 报价 / 客户)。用户填完提交后,你再用 proposeCreate 生成待确认的新增。") | ||
| 47 | + public String collectForm(@P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword) { | ||
| 40 | if (entityKeyword == null || entityKeyword.isBlank()) { | 48 | if (entityKeyword == null || entityKeyword.isBlank()) { |
| 41 | - return "{\"error\":\"缺少实体类型\"}"; | 49 | + return err("缺少实体类型。"); |
| 42 | } | 50 | } |
| 43 | Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); | 51 | Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); |
| 44 | if (form == null) { | 52 | if (form == null) { |
| 45 | - return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; | 53 | + return err("找不到「" + entityKeyword + "」对应的可新建表单。"); |
| 54 | + } | ||
| 55 | + String formId = String.valueOf(form.get("sFormId")); | ||
| 56 | + String moduleId = String.valueOf(form.get("sModuleId")); | ||
| 57 | + if (!identity.canAccessModule(moduleId)) { | ||
| 58 | + return err("你没有新建「" + entityKeyword + "」的权限。"); | ||
| 46 | } | 59 | } |
| 47 | - String table = String.valueOf(form.get("sDataSource")); | ||
| 48 | - List<String> fields = new ArrayList<>(); | ||
| 49 | - Set<String> seen = new HashSet<>(); | 60 | + |
| 61 | + List<Map<String, Object>> cols; | ||
| 50 | try { | 62 | try { |
| 51 | - List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 52 | - "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " + | ||
| 53 | - "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " + | ||
| 54 | - "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table); | ||
| 55 | - for (Map<String, Object> r : rows) { | ||
| 56 | - Object zh = r.get("sChinese"); | ||
| 57 | - if (zh != null && seen.add(zh.toString()) && fields.size() < 8) { | ||
| 58 | - fields.add(zh.toString()); | ||
| 59 | - } | 63 | + cols = jdbc.queryForList( |
| 64 | + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " + | ||
| 65 | + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " + | ||
| 66 | + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); | ||
| 67 | + } catch (Exception e) { | ||
| 68 | + return err("读取表单结构失败:" + e.getMessage()); | ||
| 69 | + } | ||
| 70 | + if (cols.isEmpty()) { | ||
| 71 | + return err("该表单没有可填写的字段配置。"); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + List<Map<String, Object>> fields = new ArrayList<>(); | ||
| 75 | + for (Map<String, Object> c : cols) { | ||
| 76 | + String name = str(c.get("sName")); | ||
| 77 | + if (name == null || name.isBlank()) { | ||
| 78 | + continue; | ||
| 79 | + } | ||
| 80 | + Map<String, Object> f = new LinkedHashMap<>(); | ||
| 81 | + f.put("name", name); | ||
| 82 | + f.put("label", firstNonBlank(str(c.get("sChinese")), name)); | ||
| 83 | + f.put("control", firstNonBlank(str(c.get("sControlName")), "text")); | ||
| 84 | + f.put("required", truthy(c.get("bNotEmpty"))); | ||
| 85 | + String def = str(c.get("sDefault")); | ||
| 86 | + if (def != null && !def.isBlank()) { | ||
| 87 | + f.put("default", def); | ||
| 88 | + } | ||
| 89 | + List<String> opts = simpleOptions(str(c.get("sChineseDropDown"))); | ||
| 90 | + if (!opts.isEmpty()) { | ||
| 91 | + f.put("options", opts); | ||
| 60 | } | 92 | } |
| 61 | - } catch (Exception ignore) { | 93 | + fields.add(f); |
| 62 | } | 94 | } |
| 63 | if (fields.isEmpty()) { | 95 | if (fields.isEmpty()) { |
| 64 | - return "{\"error\":\"该实体没有可填字段\"}"; | 96 | + return err("该表单没有可填写的字段。"); |
| 65 | } | 97 | } |
| 98 | + | ||
| 66 | Map<String, Object> out = new LinkedHashMap<>(); | 99 | Map<String, Object> out = new LinkedHashMap<>(); |
| 67 | - out.put("formCollect", true); | 100 | + out.put("type", "form_collect"); |
| 68 | out.put("entity", entityKeyword.trim()); | 101 | out.put("entity", entityKeyword.trim()); |
| 102 | + out.put("formId", formId); | ||
| 103 | + out.put("moduleId", moduleId); | ||
| 104 | + out.put("title", "新建" + entityKeyword.trim()); | ||
| 69 | out.put("fields", fields); | 105 | out.put("fields", fields); |
| 70 | - out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); | 106 | + out.put("message", "请在下方表单里填写,填完点【提交】。"); |
| 107 | + return toJson(out); | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */ | ||
| 111 | + private List<String> simpleOptions(String dropdown) { | ||
| 112 | + List<String> out = new ArrayList<>(); | ||
| 113 | + if (dropdown == null || dropdown.isBlank()) { | ||
| 114 | + return out; | ||
| 115 | + } | ||
| 116 | + String d = dropdown.trim(); | ||
| 117 | + if (d.toLowerCase().contains("select ") || d.length() > 200) { | ||
| 118 | + return out; // SQL 或过长 -> 不当作枚举 | ||
| 119 | + } | ||
| 120 | + for (String o : d.split("[、,,|]")) { | ||
| 121 | + String t = o.trim(); | ||
| 122 | + if (!t.isEmpty() && out.size() < 30) { | ||
| 123 | + out.add(t); | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + return out; | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + private static boolean truthy(Object o) { | ||
| 130 | + if (o == null) { | ||
| 131 | + return false; | ||
| 132 | + } | ||
| 133 | + if (o instanceof Boolean) { | ||
| 134 | + return (Boolean) o; | ||
| 135 | + } | ||
| 136 | + if (o instanceof Number) { | ||
| 137 | + return ((Number) o).intValue() != 0; | ||
| 138 | + } | ||
| 139 | + if (o instanceof byte[]) { | ||
| 140 | + byte[] b = (byte[]) o; | ||
| 141 | + return b.length > 0 && b[0] != 0; | ||
| 142 | + } | ||
| 143 | + String s = o.toString().trim(); | ||
| 144 | + return s.equals("1") || s.equalsIgnoreCase("true"); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + private static String firstNonBlank(String a, String b) { | ||
| 148 | + return (a != null && !a.isBlank()) ? a : b; | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + private static String str(Object o) { | ||
| 152 | + return o == null ? null : o.toString(); | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + private String err(String msg) { | ||
| 156 | + Map<String, Object> m = new LinkedHashMap<>(); | ||
| 157 | + m.put("error", msg); | ||
| 158 | + return toJson(m); | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + private String toJson(Map<String, Object> m) { | ||
| 71 | try { | 162 | try { |
| 72 | - return mapper.writeValueAsString(out); | 163 | + return mapper.writeValueAsString(m); |
| 73 | } catch (Exception e) { | 164 | } catch (Exception e) { |
| 74 | return "{\"error\":\"内部错误\"}"; | 165 | return "{\"error\":\"内部错误\"}"; |
| 75 | } | 166 | } |
src/main/java/com/xly/tool/InteractionTool.java
0 → 100644
| 1 | +package com.xly.tool; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import dev.langchain4j.agent.tool.P; | ||
| 5 | +import dev.langchain4j.agent.tool.Tool; | ||
| 6 | +import org.springframework.stereotype.Component; | ||
| 7 | + | ||
| 8 | +import java.util.ArrayList; | ||
| 9 | +import java.util.LinkedHashMap; | ||
| 10 | +import java.util.List; | ||
| 11 | +import java.util.Map; | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * AskUser 工具(架构 §5 #4)——消歧 / 澄清用的小问题:一句问题 + 若干可点选项(也允许自由输入)。 | ||
| 15 | + * | ||
| 16 | + * <p>工具返回结构化 JSON;{@code AgentChatController} 侦测到 {@code type=question} 后推一条 SSE | ||
| 17 | + * {@code question} 事件,前端渲染可点选项片。用户点选/输入的答复作为下一条消息进入对话,凭 ChatMemory | ||
| 18 | + * 自然接续(无需额外的 pending 状态机即可完成一轮澄清)。工具无状态、单例。 | ||
| 19 | + */ | ||
| 20 | +@Component | ||
| 21 | +public class InteractionTool { | ||
| 22 | + | ||
| 23 | + private final ObjectMapper mapper; | ||
| 24 | + | ||
| 25 | + public InteractionTool(ObjectMapper mapper) { | ||
| 26 | + this.mapper = mapper; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Tool("向用户提出一个澄清/消歧的小问题并给出可选项。当用户意图不明确、或需要在几个候选中二选一时使用;" | ||
| 30 | + + "问完即结束本轮、等待用户回答。options 用中文顿号或逗号分隔(如「本月、上月、本季度」),可留空表示自由回答。") | ||
| 31 | + public String askUser( | ||
| 32 | + @P("要问用户的问题") String question, | ||
| 33 | + @P(value = "候选选项,用、或,分隔;没有明确候选时留空", required = false) String options) { | ||
| 34 | + | ||
| 35 | + List<String> opts = new ArrayList<>(); | ||
| 36 | + if (options != null && !options.isBlank()) { | ||
| 37 | + for (String o : options.split("[、,,]")) { | ||
| 38 | + String t = o.trim(); | ||
| 39 | + if (!t.isEmpty()) { | ||
| 40 | + opts.add(t); | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + Map<String, Object> out = new LinkedHashMap<>(); | ||
| 45 | + out.put("type", "question"); | ||
| 46 | + out.put("question", question == null ? "" : question.trim()); | ||
| 47 | + out.put("options", opts); | ||
| 48 | + try { | ||
| 49 | + return mapper.writeValueAsString(out); | ||
| 50 | + } catch (Exception e) { | ||
| 51 | + return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[]}"; | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} |
src/main/java/com/xly/tool/KgQueryTool.java
| @@ -65,6 +65,79 @@ public class KgQueryTool { | @@ -65,6 +65,79 @@ public class KgQueryTool { | ||
| 65 | return sb.toString(); | 65 | return sb.toString(); |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | + @Tool("查知识图谱(KG):某表单的**上下游流转/相邻单据**(L2)与某字段**在哪些表/列**(L3)。" | ||
| 69 | + + "用于弄清「这张单从哪来、到哪去、和谁相关」或「某个字段落在哪张表」。入参 = 表单名或字段中文名关键词。") | ||
| 70 | + public String kgSearch(@P("表单名或字段中文名关键词,如 采购订单 / 送货 / 单价 / 数量") String keyword) { | ||
| 71 | + if (keyword == null || keyword.isBlank()) { | ||
| 72 | + return "请提供一个表单名或字段中文名关键词。"; | ||
| 73 | + } | ||
| 74 | + String kw = keyword.trim(); | ||
| 75 | + String like = "%" + kw + "%"; | ||
| 76 | + StringBuilder sb = new StringBuilder(); | ||
| 77 | + | ||
| 78 | + // L2:表单邻居 / 上下游流转 | ||
| 79 | + List<Map<String, Object>> flow = safeQuery( | ||
| 80 | + "SELECT sFormTitle, sDomain, iUpstream, iDownstream, sUpForms, sDownForms, sRefTables " + | ||
| 81 | + "FROM viw_kg_form_neighbors WHERE sFormTitle LIKE ? " + | ||
| 82 | + "ORDER BY (COALESCE(iUpstream,0)+COALESCE(iDownstream,0)) DESC LIMIT 5", like); | ||
| 83 | + if (!flow.isEmpty()) { | ||
| 84 | + sb.append("【单据流转 / 相邻单据】\n"); | ||
| 85 | + for (Map<String, Object> r : flow) { | ||
| 86 | + sb.append("- ").append(str(r.get("sFormTitle"))) | ||
| 87 | + .append("(域:").append(str(r.get("sDomain"))).append(")"); | ||
| 88 | + String up = clip(str(r.get("sUpForms")), 80); | ||
| 89 | + String down = clip(str(r.get("sDownForms")), 80); | ||
| 90 | + if (!up.isBlank()) sb.append("\n 上游←:").append(up); | ||
| 91 | + if (!down.isBlank()) sb.append("\n 下游→:").append(down); | ||
| 92 | + String ref = clip(str(r.get("sRefTables")), 80); | ||
| 93 | + if (!ref.isBlank()) sb.append("\n 引用表:").append(ref); | ||
| 94 | + sb.append('\n'); | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + // L3:字段 -> 表/列(按词查,绝不整表 dump) | ||
| 99 | + List<Map<String, Object>> fields = safeQuery( | ||
| 100 | + "SELECT sTable, sField, sChinese, sFkTable FROM viw_kg_field_dict " + | ||
| 101 | + "WHERE sChinese LIKE ? AND sTable NOT LIKE 'viw%' " + | ||
| 102 | + "ORDER BY iFormUses DESC LIMIT 8", like); | ||
| 103 | + if (!fields.isEmpty()) { | ||
| 104 | + sb.append("\n【字段所在表/列】\n"); | ||
| 105 | + for (Map<String, Object> r : fields) { | ||
| 106 | + sb.append("- ").append(str(r.get("sChinese"))) | ||
| 107 | + .append(" = ").append(str(r.get("sTable"))).append(".").append(str(r.get("sField"))); | ||
| 108 | + String fk = str(r.get("sFkTable")); | ||
| 109 | + if (fk != null && !fk.isBlank() && !"null".equalsIgnoreCase(fk)) { | ||
| 110 | + sb.append("(外键→").append(fk).append(")"); | ||
| 111 | + } | ||
| 112 | + sb.append('\n'); | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + if (sb.length() == 0) { | ||
| 117 | + return "知识图谱里没有与「" + kw + "」直接相关的流转或字段。可以换个更常见的单据名或字段名。"; | ||
| 118 | + } | ||
| 119 | + return sb.toString(); | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + private List<Map<String, Object>> safeQuery(String sql, Object... args) { | ||
| 123 | + try { | ||
| 124 | + return jdbc.queryForList(sql, args); | ||
| 125 | + } catch (Exception e) { | ||
| 126 | + return List.of(); | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + private static String clip(String s, int max) { | ||
| 131 | + if (s == null) { | ||
| 132 | + return ""; | ||
| 133 | + } | ||
| 134 | + String t = s.trim(); | ||
| 135 | + if ("null".equalsIgnoreCase(t)) { | ||
| 136 | + return ""; | ||
| 137 | + } | ||
| 138 | + return t.length() > max ? t.substring(0, max) + "…" : t; | ||
| 139 | + } | ||
| 140 | + | ||
| 68 | private static String str(Object o) { | 141 | private static String str(Object o) { |
| 69 | return o == null ? "" : o.toString(); | 142 | return o == null ? "" : o.toString(); |
| 70 | } | 143 | } |
src/main/java/com/xly/tool/ProposeWriteTool.java
| @@ -2,13 +2,12 @@ package com.xly.tool; | @@ -2,13 +2,12 @@ package com.xly.tool; | ||
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | import com.fasterxml.jackson.databind.ObjectMapper; | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | -import com.xly.service.AuthzService; | 5 | +import com.xly.agent.AgentIdentity; |
| 6 | import com.xly.service.ErpClient; | 6 | import com.xly.service.ErpClient; |
| 7 | import com.xly.service.OpService; | 7 | import com.xly.service.OpService; |
| 8 | import dev.langchain4j.agent.tool.P; | 8 | import dev.langchain4j.agent.tool.P; |
| 9 | import dev.langchain4j.agent.tool.Tool; | 9 | import dev.langchain4j.agent.tool.Tool; |
| 10 | import org.springframework.jdbc.core.JdbcTemplate; | 10 | import org.springframework.jdbc.core.JdbcTemplate; |
| 11 | -import org.springframework.stereotype.Component; | ||
| 12 | 11 | ||
| 13 | import java.util.ArrayList; | 12 | import java.util.ArrayList; |
| 14 | import java.util.Iterator; | 13 | import java.util.Iterator; |
| @@ -24,21 +23,20 @@ import java.util.Set; | @@ -24,21 +23,20 @@ import java.util.Set; | ||
| 24 | * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 | 23 | * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 |
| 25 | * (见 OpController),不经过 LLM。 | 24 | * (见 OpController),不经过 LLM。 |
| 26 | */ | 25 | */ |
| 27 | -@Component | ||
| 28 | public class ProposeWriteTool { | 26 | public class ProposeWriteTool { |
| 29 | 27 | ||
| 30 | private final ErpClient erp; | 28 | private final ErpClient erp; |
| 31 | private final JdbcTemplate jdbc; | 29 | private final JdbcTemplate jdbc; |
| 32 | private final OpService ops; | 30 | private final OpService ops; |
| 33 | private final ObjectMapper mapper; | 31 | private final ObjectMapper mapper; |
| 34 | - private final AuthzService authz; | 32 | + private final AgentIdentity identity; |
| 35 | 33 | ||
| 36 | - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) { | 34 | + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AgentIdentity identity) { |
| 37 | this.erp = erp; | 35 | this.erp = erp; |
| 38 | this.jdbc = jdbc; | 36 | this.jdbc = jdbc; |
| 39 | this.ops = ops; | 37 | this.ops = ops; |
| 40 | this.mapper = mapper; | 38 | this.mapper = mapper; |
| 41 | - this.authz = authz; | 39 | + this.identity = identity; |
| 42 | } | 40 | } |
| 43 | 41 | ||
| 44 | @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" | 42 | @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" |
| @@ -62,7 +60,7 @@ public class ProposeWriteTool { | @@ -62,7 +60,7 @@ public class ProposeWriteTool { | ||
| 62 | String formId = str(form.get("sFormId")); | 60 | String formId = str(form.get("sFormId")); |
| 63 | String moduleId = str(form.get("sModuleId")); | 61 | String moduleId = str(form.get("sModuleId")); |
| 64 | String table = str(form.get("sDataSource")); | 62 | String table = str(form.get("sDataSource")); |
| 65 | - if (!authz.canAccessModule(moduleId)) { | 63 | + if (!identity.canAccessModule(moduleId)) { |
| 66 | return err("你没有修改「" + entityKeyword + "」的权限。"); | 64 | return err("你没有修改「" + entityKeyword + "」的权限。"); |
| 67 | } | 65 | } |
| 68 | 66 | ||
| @@ -85,7 +83,7 @@ public class ProposeWriteTool { | @@ -85,7 +83,7 @@ public class ProposeWriteTool { | ||
| 85 | "ORDER BY iFormUses DESC LIMIT 1", table); | 83 | "ORDER BY iFormUses DESC LIMIT 1", table); |
| 86 | JsonNode root; | 84 | JsonNode root; |
| 87 | try { | 85 | try { |
| 88 | - root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); | 86 | + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); |
| 89 | } catch (Exception e) { | 87 | } catch (Exception e) { |
| 90 | return err("定位记录时读取失败:" + e.getMessage()); | 88 | return err("定位记录时读取失败:" + e.getMessage()); |
| 91 | } | 89 | } |
| @@ -118,7 +116,7 @@ public class ProposeWriteTool { | @@ -118,7 +116,7 @@ public class ProposeWriteTool { | ||
| 118 | // 4) 暂存 draft(不执行) | 116 | // 4) 暂存 draft(不执行) |
| 119 | String description = "将【" + recordName + "】的【" + fieldChinese + "】" | 117 | String description = "将【" + recordName + "】的【" + fieldChinese + "】" |
| 120 | + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; | 118 | + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; |
| 121 | - String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId, | 119 | + String opId = ops.createDraft(identity.userId(), "update", formId.trim(), moduleId.trim(), table, billId, |
| 122 | field, fieldChinese, oldValue, newValue, description); | 120 | field, fieldChinese, oldValue, newValue, description); |
| 123 | 121 | ||
| 124 | Map<String, Object> out = new LinkedHashMap<>(); | 122 | Map<String, Object> out = new LinkedHashMap<>(); |
| @@ -144,7 +142,7 @@ public class ProposeWriteTool { | @@ -144,7 +142,7 @@ public class ProposeWriteTool { | ||
| 144 | String formId = str(form.get("sFormId")); | 142 | String formId = str(form.get("sFormId")); |
| 145 | String moduleId = str(form.get("sModuleId")); | 143 | String moduleId = str(form.get("sModuleId")); |
| 146 | String table = str(form.get("sDataSource")); | 144 | String table = str(form.get("sDataSource")); |
| 147 | - if (!authz.canAccessModule(moduleId)) { | 145 | + if (!identity.canAccessModule(moduleId)) { |
| 148 | return err("你没有操作「" + entityKeyword + "」的权限。"); | 146 | return err("你没有操作「" + entityKeyword + "」的权限。"); |
| 149 | } | 147 | } |
| 150 | String nameField = queryOne( | 148 | String nameField = queryOne( |
| @@ -152,7 +150,7 @@ public class ProposeWriteTool { | @@ -152,7 +150,7 @@ public class ProposeWriteTool { | ||
| 152 | "ORDER BY iFormUses DESC LIMIT 1", table); | 150 | "ORDER BY iFormUses DESC LIMIT 1", table); |
| 153 | JsonNode root; | 151 | JsonNode root; |
| 154 | try { | 152 | try { |
| 155 | - root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim()); | 153 | + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); |
| 156 | } catch (Exception e) { | 154 | } catch (Exception e) { |
| 157 | return err("定位记录失败:" + e.getMessage()); | 155 | return err("定位记录失败:" + e.getMessage()); |
| 158 | } | 156 | } |
| @@ -180,7 +178,7 @@ public class ProposeWriteTool { | @@ -180,7 +178,7 @@ public class ProposeWriteTool { | ||
| 180 | } | 178 | } |
| 181 | String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | 179 | String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); |
| 182 | String description = "删除【" + recordName + "】(" + entityKeyword + ")"; | 180 | String description = "删除【" + recordName + "】(" + entityKeyword + ")"; |
| 183 | - String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId, | 181 | + String opId = ops.createDraft(identity.userId(), "delete", formId, moduleId, table, billId, |
| 184 | null, null, recordName, null, description); | 182 | null, null, recordName, null, description); |
| 185 | 183 | ||
| 186 | Map<String, Object> out = new LinkedHashMap<>(); | 184 | Map<String, Object> out = new LinkedHashMap<>(); |
| @@ -206,7 +204,7 @@ public class ProposeWriteTool { | @@ -206,7 +204,7 @@ public class ProposeWriteTool { | ||
| 206 | String formId = str(form.get("sFormId")); | 204 | String formId = str(form.get("sFormId")); |
| 207 | String moduleId = str(form.get("sModuleId")); | 205 | String moduleId = str(form.get("sModuleId")); |
| 208 | String table = str(form.get("sDataSource")); | 206 | String table = str(form.get("sDataSource")); |
| 209 | - if (!authz.canAccessModule(moduleId)) { | 207 | + if (!identity.canAccessModule(moduleId)) { |
| 210 | return err("你没有新增「" + entityKeyword + "」的权限。"); | 208 | return err("你没有新增「" + entityKeyword + "」的权限。"); |
| 211 | } | 209 | } |
| 212 | 210 | ||
| @@ -242,7 +240,7 @@ public class ProposeWriteTool { | @@ -242,7 +240,7 @@ public class ProposeWriteTool { | ||
| 242 | continue; | 240 | continue; |
| 243 | } | 241 | } |
| 244 | if ("sId".equals(rc)) { | 242 | if ("sId".equals(rc)) { |
| 245 | - col.put(rc, erp.newUuid()); | 243 | + col.put(rc, erp.newUuid(identity.token())); |
| 246 | } else if (rc.endsWith("Id")) { | 244 | } else if (rc.endsWith("Id")) { |
| 247 | String d = commonValue(table, rc); // 外键:取现有最常见值兜底 | 245 | String d = commonValue(table, rc); // 外键:取现有最常见值兜底 |
| 248 | col.put(rc, d == null ? "" : d); | 246 | col.put(rc, d == null ? "" : d); |
| @@ -252,7 +250,7 @@ public class ProposeWriteTool { | @@ -252,7 +250,7 @@ public class ProposeWriteTool { | ||
| 252 | col.put(rc, ""); | 250 | col.put(rc, ""); |
| 253 | } | 251 | } |
| 254 | } | 252 | } |
| 255 | - col.putIfAbsent("sId", erp.newUuid()); | 253 | + col.putIfAbsent("sId", erp.newUuid(identity.token())); |
| 256 | 254 | ||
| 257 | String payload; | 255 | String payload; |
| 258 | try { | 256 | try { |
| @@ -261,7 +259,7 @@ public class ProposeWriteTool { | @@ -261,7 +259,7 @@ public class ProposeWriteTool { | ||
| 261 | return err("内部错误:" + e.getMessage()); | 259 | return err("内部错误:" + e.getMessage()); |
| 262 | } | 260 | } |
| 263 | String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); | 261 | String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); |
| 264 | - String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); | 262 | + String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description); |
| 265 | 263 | ||
| 266 | Map<String, Object> out = new LinkedHashMap<>(); | 264 | Map<String, Object> out = new LinkedHashMap<>(); |
| 267 | out.put("opId", opId); | 265 | out.put("opId", opId); |
| @@ -270,6 +268,69 @@ public class ProposeWriteTool { | @@ -270,6 +268,69 @@ public class ProposeWriteTool { | ||
| 270 | return toJson(out); | 268 | return toJson(out); |
| 271 | } | 269 | } |
| 272 | 270 | ||
| 271 | + @Tool("提议**审核/过账**某条单据(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正审核。" | ||
| 272 | + + "用于「审核 / 过账某张单据」这类需求;审核有业务后果,请谨慎。给出单据类型与单号/名称即可。") | ||
| 273 | + public String proposeExamine( | ||
| 274 | + @P("单据类型,如 销售订单 / 采购订单 / 报价") String entityKeyword, | ||
| 275 | + @P("要审核的单据编号或名称关键词") String recordKeyword) { | ||
| 276 | + | ||
| 277 | + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { | ||
| 278 | + return err("缺少单据类型或单号/名称。"); | ||
| 279 | + } | ||
| 280 | + Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 281 | + if (form == null) { | ||
| 282 | + return err("找不到「" + entityKeyword + "」对应的可审核单据主表。"); | ||
| 283 | + } | ||
| 284 | + String formId = str(form.get("sFormId")); | ||
| 285 | + String moduleId = str(form.get("sModuleId")); | ||
| 286 | + String table = str(form.get("sDataSource")); | ||
| 287 | + if (!identity.canAccessModule(moduleId)) { | ||
| 288 | + return err("你没有审核「" + entityKeyword + "」的权限。"); | ||
| 289 | + } | ||
| 290 | + String nameField = queryOne( | ||
| 291 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 292 | + "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 293 | + JsonNode root; | ||
| 294 | + try { | ||
| 295 | + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); | ||
| 296 | + } catch (Exception e) { | ||
| 297 | + return err("定位单据失败:" + e.getMessage()); | ||
| 298 | + } | ||
| 299 | + if (root.path("code").asInt(0) < 0) { | ||
| 300 | + return err("定位单据失败:" + root.path("msg").asText("未知错误")); | ||
| 301 | + } | ||
| 302 | + JsonNode rows = root.path("dataset").path("rows"); | ||
| 303 | + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 304 | + int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 305 | + if (n == 0) { | ||
| 306 | + return err("没有找到含「" + recordKeyword + "」的单据。"); | ||
| 307 | + } | ||
| 308 | + if (n > 1) { | ||
| 309 | + StringBuilder names = new StringBuilder(); | ||
| 310 | + for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 311 | + if (i > 0) names.append("、"); | ||
| 312 | + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 313 | + } | ||
| 314 | + return err("匹配到多条单据(" + names + "),请提供更精确的单号,只审核其中一条。"); | ||
| 315 | + } | ||
| 316 | + JsonNode rec = data.get(0); | ||
| 317 | + String billId = rec.path("sId").asText(null); | ||
| 318 | + if (isBlank(billId)) { | ||
| 319 | + return err("定位到的单据缺少主键 sId,无法审核。"); | ||
| 320 | + } | ||
| 321 | + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | ||
| 322 | + String description = "审核【" + recordName + "】(" + entityKeyword + ")"; | ||
| 323 | + // examine:sNewValue 存 iFlag(1=审核);执行走 ERP doExamine(存储过程驱动) | ||
| 324 | + String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, | ||
| 325 | + null, null, null, "1", description); | ||
| 326 | + | ||
| 327 | + Map<String, Object> out = new LinkedHashMap<>(); | ||
| 328 | + out.put("opId", opId); | ||
| 329 | + out.put("summary", description); | ||
| 330 | + out.put("message", "已为你生成一条待确认的审核,请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); | ||
| 331 | + return toJson(out); | ||
| 332 | + } | ||
| 333 | + | ||
| 273 | /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ | 334 | /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ |
| 274 | private List<String> requiredCols(String table) { | 335 | private List<String> requiredCols(String table) { |
| 275 | List<String> out = new ArrayList<>(); | 336 | List<String> out = new ArrayList<>(); |
src/main/java/com/xly/tool/QueryTool.java
| 1 | package com.xly.tool; | 1 | package com.xly.tool; |
| 2 | 2 | ||
| 3 | +import com.xly.agent.AgentIdentity; | ||
| 3 | import com.xly.service.AuditService; | 4 | import com.xly.service.AuditService; |
| 4 | import dev.langchain4j.agent.tool.P; | 5 | import dev.langchain4j.agent.tool.P; |
| 5 | import dev.langchain4j.agent.tool.Tool; | 6 | import dev.langchain4j.agent.tool.Tool; |
| @@ -7,9 +8,7 @@ import dev.langchain4j.model.ollama.OllamaChatModel; | @@ -7,9 +8,7 @@ import dev.langchain4j.model.ollama.OllamaChatModel; | ||
| 7 | import net.sf.jsqlparser.parser.CCJSqlParserUtil; | 8 | import net.sf.jsqlparser.parser.CCJSqlParserUtil; |
| 8 | import net.sf.jsqlparser.statement.Statement; | 9 | import net.sf.jsqlparser.statement.Statement; |
| 9 | import net.sf.jsqlparser.statement.select.Select; | 10 | import net.sf.jsqlparser.statement.select.Select; |
| 10 | -import org.springframework.beans.factory.annotation.Qualifier; | ||
| 11 | import org.springframework.jdbc.core.JdbcTemplate; | 11 | import org.springframework.jdbc.core.JdbcTemplate; |
| 12 | -import org.springframework.stereotype.Component; | ||
| 13 | 12 | ||
| 14 | import java.util.List; | 13 | import java.util.List; |
| 15 | import java.util.Map; | 14 | import java.util.Map; |
| @@ -22,17 +21,18 @@ import java.util.Map; | @@ -22,17 +21,18 @@ import java.util.Map; | ||
| 22 | * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → | 21 | * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → |
| 23 | * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 | 22 | * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 |
| 24 | */ | 23 | */ |
| 25 | -@Component | ||
| 26 | public class QueryTool { | 24 | public class QueryTool { |
| 27 | 25 | ||
| 28 | private final OllamaChatModel sqlModel; | 26 | private final OllamaChatModel sqlModel; |
| 29 | private final JdbcTemplate jdbc; | 27 | private final JdbcTemplate jdbc; |
| 30 | private final AuditService audit; | 28 | private final AuditService audit; |
| 29 | + private final AgentIdentity identity; | ||
| 31 | 30 | ||
| 32 | - public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) { | 31 | + public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity) { |
| 33 | this.sqlModel = sqlModel; | 32 | this.sqlModel = sqlModel; |
| 34 | this.jdbc = jdbc; | 33 | this.jdbc = jdbc; |
| 35 | this.audit = audit; | 34 | this.audit = audit; |
| 35 | + this.identity = identity; | ||
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" | 38 | @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" |
src/main/java/com/xly/tool/SkillTool.java
0 → 100644
| 1 | +package com.xly.tool; | ||
| 2 | + | ||
| 3 | +import com.xly.service.SkillService; | ||
| 4 | +import dev.langchain4j.agent.tool.P; | ||
| 5 | +import dev.langchain4j.agent.tool.Tool; | ||
| 6 | +import org.springframework.stereotype.Component; | ||
| 7 | + | ||
| 8 | +import java.util.Map; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * Skill 加载工具(架构 §6,渐进披露)。 | ||
| 12 | + * | ||
| 13 | + * <p>system prompt 里只常驻 Skill 的「name + 何时用」摘要;当用户的需求命中某个 Skill 时,agent 调 | ||
| 14 | + * {@code load_skill(name)} 拉取该 Skill 的完整 playbook(详细步骤 + 建议工具)再照做。Skill 是无状态、 | ||
| 15 | + * 全局的,因此本工具是单例。 | ||
| 16 | + */ | ||
| 17 | +@Component | ||
| 18 | +public class SkillTool { | ||
| 19 | + | ||
| 20 | + private final SkillService skills; | ||
| 21 | + | ||
| 22 | + public SkillTool(SkillService skills) { | ||
| 23 | + this.skills = skills; | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + @Tool("加载某个业务 Skill(playbook)的完整操作指南。当用户的需求匹配 system prompt 中列出的某个 Skill 名时," | ||
| 27 | + + "先调用它拿到详细步骤,再按步骤用其它工具完成。入参 = Skill 名称(如 新建报价 / 月度对账 / 库存查询)。") | ||
| 28 | + public String loadSkill(@P("Skill 名称") String name) { | ||
| 29 | + Map<String, Object> s = skills.load(name); | ||
| 30 | + if (s == null) { | ||
| 31 | + return "没有找到名为「" + name + "」的 Skill。可用的 Skill 见系统提示里的清单。"; | ||
| 32 | + } | ||
| 33 | + String instr = String.valueOf(s.get("sInstructions")); | ||
| 34 | + Object suggested = s.get("sSuggested"); | ||
| 35 | + StringBuilder sb = new StringBuilder(); | ||
| 36 | + sb.append("【Skill:").append(s.get("sName")).append("】\n").append(instr); | ||
| 37 | + if (suggested != null && !String.valueOf(suggested).isBlank()) { | ||
| 38 | + sb.append("\n建议用到的工具:").append(suggested); | ||
| 39 | + } | ||
| 40 | + return sb.toString(); | ||
| 41 | + } | ||
| 42 | +} |
src/main/java/com/xly/web/AgentChatController.java
| @@ -2,7 +2,10 @@ package com.xly.web; | @@ -2,7 +2,10 @@ package com.xly.web; | ||
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | import com.fasterxml.jackson.databind.ObjectMapper; | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | +import com.xly.agent.AgentIdentity; | ||
| 5 | import com.xly.agent.ReActAgent; | 6 | import com.xly.agent.ReActAgent; |
| 7 | +import com.xly.config.AgentFactory; | ||
| 8 | +import com.xly.service.AuthzService; | ||
| 6 | import com.xly.service.ConversationService; | 9 | import com.xly.service.ConversationService; |
| 7 | import com.xly.service.OpService; | 10 | import com.xly.service.OpService; |
| 8 | import dev.langchain4j.service.TokenStream; | 11 | import dev.langchain4j.service.TokenStream; |
| @@ -19,31 +22,39 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | @@ -19,31 +22,39 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||
| 19 | import java.io.IOException; | 22 | import java.io.IOException; |
| 20 | import java.util.LinkedHashMap; | 23 | import java.util.LinkedHashMap; |
| 21 | import java.util.Map; | 24 | import java.util.Map; |
| 25 | +import java.util.Set; | ||
| 22 | import java.util.concurrent.ExecutorService; | 26 | import java.util.concurrent.ExecutorService; |
| 23 | import java.util.concurrent.Executors; | 27 | import java.util.concurrent.Executors; |
| 24 | 28 | ||
| 25 | /** | 29 | /** |
| 26 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 | 30 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 |
| 27 | * | 31 | * |
| 28 | - * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}。 | ||
| 29 | - * {@code reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片 | ||
| 30 | - * (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。 | 32 | + * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、 |
| 33 | + * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。 | ||
| 34 | + * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 | ||
| 35 | + * | ||
| 36 | + * <p>**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份, | ||
| 37 | + * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。 | ||
| 31 | */ | 38 | */ |
| 32 | @RestController | 39 | @RestController |
| 33 | @RequestMapping("/api/agent") | 40 | @RequestMapping("/api/agent") |
| 34 | public class AgentChatController { | 41 | public class AgentChatController { |
| 35 | 42 | ||
| 36 | private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); | 43 | private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); |
| 44 | + private static final Set<String> WRITE_TOOLS = | ||
| 45 | + Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine"); | ||
| 37 | 46 | ||
| 38 | - private final ReActAgent agent; | 47 | + private final AgentFactory agentFactory; |
| 48 | + private final AuthzService authz; | ||
| 39 | private final ObjectMapper mapper; | 49 | private final ObjectMapper mapper; |
| 40 | private final ConversationService conversations; | 50 | private final ConversationService conversations; |
| 41 | private final OpService ops; | 51 | private final OpService ops; |
| 42 | private final ExecutorService exec = Executors.newCachedThreadPool(); | 52 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 43 | 53 | ||
| 44 | - public AgentChatController(ReActAgent agent, ObjectMapper mapper, | 54 | + public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, |
| 45 | ConversationService conversations, OpService ops) { | 55 | ConversationService conversations, OpService ops) { |
| 46 | - this.agent = agent; | 56 | + this.agentFactory = agentFactory; |
| 57 | + this.authz = authz; | ||
| 47 | this.mapper = mapper; | 58 | this.mapper = mapper; |
| 48 | this.conversations = conversations; | 59 | this.conversations = conversations; |
| 49 | this.ops = ops; | 60 | this.ops = ops; |
| @@ -53,6 +64,12 @@ public class AgentChatController { | @@ -53,6 +64,12 @@ public class AgentChatController { | ||
| 53 | public String text; | 64 | public String text; |
| 54 | public String userid; | 65 | public String userid; |
| 55 | public String conversationId; | 66 | public String conversationId; |
| 67 | + // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) | ||
| 68 | + public String authorization; | ||
| 69 | + public String username; | ||
| 70 | + public String brandsid; | ||
| 71 | + public String subsidiaryid; | ||
| 72 | + public String usertype; | ||
| 56 | } | 73 | } |
| 57 | 74 | ||
| 58 | @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") | 75 | @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") |
| @@ -65,6 +82,9 @@ public class AgentChatController { | @@ -65,6 +82,9 @@ public class AgentChatController { | ||
| 65 | 82 | ||
| 66 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); | 83 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); |
| 67 | 84 | ||
| 85 | + final AgentIdentity identity = resolveIdentity(req); | ||
| 86 | + final ReActAgent agent = agentFactory.build(identity); | ||
| 87 | + | ||
| 68 | exec.submit(() -> { | 88 | exec.submit(() -> { |
| 69 | try { | 89 | try { |
| 70 | TokenStream ts = agent.chat(convId, userInput); | 90 | TokenStream ts = agent.chat(convId, userInput); |
| @@ -89,13 +109,30 @@ public class AgentChatController { | @@ -89,13 +109,30 @@ public class AgentChatController { | ||
| 89 | return emitter; | 109 | return emitter; |
| 90 | } | 110 | } |
| 91 | 111 | ||
| 92 | - /** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */ | 112 | + /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ |
| 113 | + private AgentIdentity resolveIdentity(ChatReq req) { | ||
| 114 | + try { | ||
| 115 | + if (req.authorization != null && !req.authorization.isBlank()) { | ||
| 116 | + return authz.userIdentity(req.authorization.trim(), req.userid, req.username, | ||
| 117 | + req.brandsid, req.subsidiaryid, req.usertype); | ||
| 118 | + } | ||
| 119 | + } catch (Exception e) { | ||
| 120 | + log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage()); | ||
| 121 | + } | ||
| 122 | + return authz.devIdentity(); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /** | ||
| 126 | + * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。 | ||
| 127 | + */ | ||
| 93 | private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { | 128 | private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { |
| 94 | send(emitter, "reset", ""); | 129 | send(emitter, "reset", ""); |
| 95 | try { | 130 | try { |
| 96 | String toolName = te.request() == null ? "" : te.request().name(); | 131 | String toolName = te.request() == null ? "" : te.request().name(); |
| 97 | - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName) | ||
| 98 | - || "proposeCreate".equals(toolName)) && te.result() != null) { | 132 | + if (te.result() == null) { |
| 133 | + return; | ||
| 134 | + } | ||
| 135 | + if (WRITE_TOOLS.contains(toolName)) { | ||
| 99 | JsonNode r = mapper.readTree(te.result()); | 136 | JsonNode r = mapper.readTree(te.result()); |
| 100 | String opId = r.path("opId").asText(null); | 137 | String opId = r.path("opId").asText(null); |
| 101 | if (opId != null && !opId.isBlank()) { | 138 | if (opId != null && !opId.isBlank()) { |
| @@ -106,9 +143,16 @@ public class AgentChatController { | @@ -106,9 +143,16 @@ public class AgentChatController { | ||
| 106 | card.put("summary", r.path("summary").asText("")); | 143 | card.put("summary", r.path("summary").asText("")); |
| 107 | sendEvent(emitter, card); | 144 | sendEvent(emitter, card); |
| 108 | } | 145 | } |
| 146 | + } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { | ||
| 147 | + // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件 | ||
| 148 | + JsonNode r = mapper.readTree(te.result()); | ||
| 149 | + String type = r.path("type").asText(""); | ||
| 150 | + if ("question".equals(type) || "form_collect".equals(type)) { | ||
| 151 | + sendEvent(emitter, mapper.convertValue(r, Map.class)); | ||
| 152 | + } | ||
| 109 | } | 153 | } |
| 110 | } catch (Exception e) { | 154 | } catch (Exception e) { |
| 111 | - log.warn("handle proposeUpdate result failed", e); | 155 | + log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); |
| 112 | } | 156 | } |
| 113 | } | 157 | } |
| 114 | 158 |
src/main/java/com/xly/web/OpController.java
| @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; | @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; | ||
| 10 | import org.springframework.web.bind.annotation.GetMapping; | 10 | import org.springframework.web.bind.annotation.GetMapping; |
| 11 | import org.springframework.web.bind.annotation.PathVariable; | 11 | import org.springframework.web.bind.annotation.PathVariable; |
| 12 | import org.springframework.web.bind.annotation.PostMapping; | 12 | import org.springframework.web.bind.annotation.PostMapping; |
| 13 | +import org.springframework.web.bind.annotation.RequestHeader; | ||
| 13 | import org.springframework.web.bind.annotation.RequestMapping; | 14 | import org.springframework.web.bind.annotation.RequestMapping; |
| 14 | import org.springframework.web.bind.annotation.RequestParam; | 15 | import org.springframework.web.bind.annotation.RequestParam; |
| 15 | import org.springframework.web.bind.annotation.RestController; | 16 | import org.springframework.web.bind.annotation.RestController; |
| @@ -48,9 +49,14 @@ public class OpController { | @@ -48,9 +49,14 @@ public class OpController { | ||
| 48 | return op == null ? Map.of() : op; | 49 | return op == null ? Map.of() : op; |
| 49 | } | 50 | } |
| 50 | 51 | ||
| 51 | - /** 确认执行:调 ERP 更新,回写状态。 */ | 52 | + /** |
| 53 | + * 确认执行:调 ERP 执行暂存的写操作,回写状态。 | ||
| 54 | + * <p>{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份 | ||
| 55 | + * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。 | ||
| 56 | + */ | ||
| 52 | @PostMapping("/{id}/confirm") | 57 | @PostMapping("/{id}/confirm") |
| 53 | - public Map<String, Object> confirm(@PathVariable("id") String id) { | 58 | + public Map<String, Object> confirm(@PathVariable("id") String id, |
| 59 | + @RequestHeader(value = "Authorization", required = false) String authToken) { | ||
| 54 | Map<String, Object> op = ops.get(id); | 60 | Map<String, Object> op = ops.get(id); |
| 55 | if (op == null) { | 61 | if (op == null) { |
| 56 | return result("failed", "找不到该操作", null); | 62 | return result("failed", "找不到该操作", null); |
| @@ -68,11 +74,14 @@ public class OpController { | @@ -68,11 +74,14 @@ public class OpController { | ||
| 68 | if ("create".equals(opType)) { | 74 | if ("create".equals(opType)) { |
| 69 | @SuppressWarnings("unchecked") | 75 | @SuppressWarnings("unchecked") |
| 70 | Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); | 76 | Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); |
| 71 | - r = erp.createForm(str(op.get("sTargetTable")), columns); | 77 | + r = erp.createForm(authToken, str(op.get("sTargetTable")), columns); |
| 72 | } else if ("delete".equals(opType)) { | 78 | } else if ("delete".equals(opType)) { |
| 73 | - r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); | 79 | + r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); |
| 80 | + } else if ("examine".equals(opType)) { | ||
| 81 | + int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核 | ||
| 82 | + r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag); | ||
| 74 | } else { | 83 | } else { |
| 75 | - r = erp.updateForm( | 84 | + r = erp.updateForm(authToken, |
| 76 | str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), | 85 | str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), |
| 77 | str(op.get("sField")), str(op.get("sNewValue"))); | 86 | str(op.get("sField")), str(op.get("sNewValue"))); |
| 78 | } | 87 | } |
src/main/resources/templates/chat.html
| @@ -711,6 +711,8 @@ | @@ -711,6 +711,8 @@ | ||
| 711 | $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); | 711 | $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); |
| 712 | } else if (evt.type === "write_proposal") { | 712 | } else if (evt.type === "write_proposal") { |
| 713 | renderProposalCard(evt.opId, evt.summary); | 713 | renderProposalCard(evt.opId, evt.summary); |
| 714 | + } else if (evt.type === "form_collect") { | ||
| 715 | + renderFormCollect(evt.entity, evt.fields || []); | ||
| 714 | } else if (evt.type === "error") { | 716 | } else if (evt.type === "error") { |
| 715 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } | 717 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } |
| 716 | aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; | 718 | aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; |
| @@ -793,6 +795,41 @@ | @@ -793,6 +795,41 @@ | ||
| 793 | card.find('.op-result').text('已取消'); | 795 | card.find('.op-result').text('已取消'); |
| 794 | } | 796 | } |
| 795 | 797 | ||
| 798 | + // ====================== FormCollect:对话内动态表单 ====================== | ||
| 799 | + function renderFormCollect(entity, fields) { | ||
| 800 | + hideTypingIndicator(); | ||
| 801 | + const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); | ||
| 802 | + const inputs = (fields || []).map(f => | ||
| 803 | + `<div style="margin-bottom:8px;"><label style="display:inline-block;width:120px;color:#555;">${escapeHtml(f)}</label>` + | ||
| 804 | + `<input data-label="${escapeHtml(f)}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;"></div>` | ||
| 805 | + ).join(''); | ||
| 806 | + const html = ` | ||
| 807 | + <div class="message ai-message" id="${fid}"> | ||
| 808 | + <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;"> | ||
| 809 | + <div class="message-content"> | ||
| 810 | + <div style="font-weight:600;margin-bottom:10px;">📝 填写「${escapeHtml(entity)}」信息</div> | ||
| 811 | + ${inputs} | ||
| 812 | + <div style="margin-top:8px;"> | ||
| 813 | + <button class="fc-submit" style="padding:8px 18px;border:none;border-radius:16px;background:linear-gradient(90deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-weight:600;">提交</button> | ||
| 814 | + </div> | ||
| 815 | + </div> | ||
| 816 | + </div> | ||
| 817 | + </div>`; | ||
| 818 | + $('#chatMessages').append(html); | ||
| 819 | + $(`#${fid} .fc-submit`).on('click', function () { | ||
| 820 | + const parts = []; | ||
| 821 | + $(`#${fid} .fc-input`).each(function () { | ||
| 822 | + const v = ($(this).val() || '').trim(); | ||
| 823 | + if (v) parts.push($(this).data('label') + '=' + v); | ||
| 824 | + }); | ||
| 825 | + if (parts.length === 0) { alert('请至少填写一个字段'); return; } | ||
| 826 | + $(this).prop('disabled', true).text('已提交'); | ||
| 827 | + $('#messageInput').val('为「' + entity + '」新增,请据此生成新增提议:' + parts.join(',')); | ||
| 828 | + sendMessage(); | ||
| 829 | + }); | ||
| 830 | + scrollToBottom(); | ||
| 831 | + } | ||
| 832 | + | ||
| 796 | // ============================== | 833 | // ============================== |
| 797 | // 👇 语音排队播放函数(保证顺序) | 834 | // 👇 语音排队播放函数(保证顺序) |
| 798 | // ============================== | 835 | // ============================== |