Commit 0a4a32365ecc946d16d570feb88da8922bab605e
1 parent
ab8e9d2d
refactor(agent): slim to 6 fixed tools, unified prompt, 4-class intent gate
- delete queryData (QueryTool, sqlChatModel bean, llm.sql-model, jsqlparser dep) - delete kgSearch (findForms stays), delete loadSkill (SkillTool/SkillService) - single system prompt (domain map + 业务常识) replaces 3 ToolScope versions; ToolScope removed - intent gate reduced to 查询/新增/操作已有单据/其他 with class definitions only; write action derived from utterance in router - dedup: shared locateRecord() in ProposeWriteTool; name-field/label lookups consolidated into FormResolverService
Showing
17 changed files
with
202 additions
and
1090 deletions
pom.xml
| @@ -101,13 +101,6 @@ | @@ -101,13 +101,6 @@ | ||
| 101 | <artifactId>langchain4j-open-ai</artifactId> | 101 | <artifactId>langchain4j-open-ai</artifactId> |
| 102 | <version>${langchain4j.version}</version> | 102 | <version>${langchain4j.version}</version> |
| 103 | </dependency> | 103 | </dependency> |
| 104 | - | ||
| 105 | - <!-- SQL 解析(QueryTool 只读沙箱护栏) --> | ||
| 106 | - <dependency> | ||
| 107 | - <groupId>com.github.jsqlparser</groupId> | ||
| 108 | - <artifactId>jsqlparser</artifactId> | ||
| 109 | - <version>4.9</version> | ||
| 110 | - </dependency> | ||
| 111 | </dependencies> | 104 | </dependencies> |
| 112 | 105 | ||
| 113 | <build> | 106 | <build> |
src/main/java/com/xly/agent/Intent.java
| @@ -7,19 +7,17 @@ import java.util.List; | @@ -7,19 +7,17 @@ import java.util.List; | ||
| 7 | * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 | 7 | * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 |
| 8 | * | 8 | * |
| 9 | * <p>由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 | 9 | * <p>由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 |
| 10 | - * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**, | ||
| 11 | - * 而不是让弱模型在全部 9 个工具里盲选——这是治理「理解错/选错工具」的核心。 | 10 | + * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**: |
| 11 | + * 新增→确定性表单、操作已有单据→写槽位抽取+确定性 proposeWrite、查询/其他→ReAct agent。 | ||
| 12 | + * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 | ||
| 12 | */ | 13 | */ |
| 13 | public class Intent { | 14 | public class Intent { |
| 14 | 15 | ||
| 15 | - /** 操作意图。 */ | 16 | + /** 操作意图(4 类)。 */ |
| 16 | public static final String QUERY = "查询"; | 17 | public static final String QUERY = "查询"; |
| 17 | public static final String CREATE = "新增"; | 18 | public static final String CREATE = "新增"; |
| 18 | - public static final String UPDATE = "修改"; | ||
| 19 | - public static final String DELETE = "删除"; | ||
| 20 | - public static final String EXAMINE = "审核"; | ||
| 21 | - public static final String CHAT = "闲聊"; | ||
| 22 | - public static final String UNCLEAR = "不清楚"; | 19 | + public static final String OPERATE = "操作已有单据"; |
| 20 | + public static final String OTHER = "其他"; | ||
| 23 | 21 | ||
| 24 | public static class Entity { | 22 | public static class Entity { |
| 25 | public String value; | 23 | public String value; |
| @@ -36,7 +34,7 @@ public class Intent { | @@ -36,7 +34,7 @@ public class Intent { | ||
| 36 | public String newValue = ""; // 修改后的新值(删除/审核留空) | 34 | public String newValue = ""; // 修改后的新值(删除/审核留空) |
| 37 | } | 35 | } |
| 38 | 36 | ||
| 39 | - public String intent = UNCLEAR; | 37 | + public String intent = OTHER; |
| 40 | public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 | 38 | public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 |
| 41 | public List<Entity> entities = new ArrayList<>(); | 39 | public List<Entity> entities = new ArrayList<>(); |
| 42 | public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息 | 40 | public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息 |
src/main/java/com/xly/agent/ReActAgent.java
| @@ -11,8 +11,8 @@ import dev.langchain4j.service.UserMessage; | @@ -11,8 +11,8 @@ import dev.langchain4j.service.UserMessage; | ||
| 11 | * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。 | 11 | * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。 |
| 12 | * | 12 | * |
| 13 | * <p>但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService}) | 13 | * <p>但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService}) |
| 14 | - * 判类,再由 {@link com.xly.config.AgentFactory} 按 {@link ToolScope} 只把该意图相关的工具装进本实例 | ||
| 15 | - * (新增意图更是完全绕过 ReAct,走确定性 collectForm 路径)。本接口只负责「给定这批工具,自己决定怎么用」。 | 14 | + * 判类,新增/操作已有单据走确定性路径(collectForm / proposeWrite)完全绕过 ReAct; |
| 15 | + * 本接口只负责「给定固定的 6 个工具,自己决定怎么用」。 | ||
| 16 | * | 16 | * |
| 17 | * <p>返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 | 17 | * <p>返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 |
| 18 | */ | 18 | */ |
src/main/java/com/xly/agent/ToolScope.java deleted
| 1 | -package com.xly.agent; | ||
| 2 | - | ||
| 3 | -/** | ||
| 4 | - * 按意图收窄「本轮可见的工具集」(架构 §5 的 tool scoping)。 | ||
| 5 | - * | ||
| 6 | - * <p>依据:单次 ReAct 的选工具准确率随工具数量上升而骤降(业界经验:50 个工具 ~94% → 200 个 ~64%)。 | ||
| 7 | - * 弱模型(qwen3:14b Q4)一次性面对全部 9 个语义重叠的工具时,频繁选错族(把「查询」当「新增」)。 | ||
| 8 | - * 于是先用意图门判类,再**只暴露该意图相关的 5 个工具**给 ReAct 执行——大幅降低选错概率。 | ||
| 9 | - */ | ||
| 10 | -public enum ToolScope { | ||
| 11 | - /** 读:findForms/kgSearch/readFormData/lookupRecord/queryData(+askUser/loadSkill)。 */ | ||
| 12 | - READ, | ||
| 13 | - /** 写:collectForm/proposeWrite(action=…)(+readFormData·lookupRecord 定位、askUser/loadSkill)。 */ | ||
| 14 | - WRITE, | ||
| 15 | - /** 兜底:全部 9 个工具(意图门失败时用)。 */ | ||
| 16 | - FULL | ||
| 17 | -} |
src/main/java/com/xly/config/AgentFactory.java
| @@ -3,22 +3,16 @@ package com.xly.config; | @@ -3,22 +3,16 @@ package com.xly.config; | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper; | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | import com.xly.agent.AgentIdentity; | 4 | import com.xly.agent.AgentIdentity; |
| 5 | import com.xly.agent.ReActAgent; | 5 | import com.xly.agent.ReActAgent; |
| 6 | -import com.xly.agent.ToolScope; | ||
| 7 | -import com.xly.service.AuditService; | ||
| 8 | import com.xly.service.ErpClient; | 6 | import com.xly.service.ErpClient; |
| 9 | import com.xly.service.FormResolverService; | 7 | import com.xly.service.FormResolverService; |
| 10 | import com.xly.service.OpService; | 8 | import com.xly.service.OpService; |
| 11 | -import com.xly.service.SkillService; | ||
| 12 | import com.xly.service.SystemPromptService; | 9 | import com.xly.service.SystemPromptService; |
| 13 | import com.xly.tool.ErpReadTool; | 10 | import com.xly.tool.ErpReadTool; |
| 14 | import com.xly.tool.FormCollectTool; | 11 | import com.xly.tool.FormCollectTool; |
| 15 | import com.xly.tool.InteractionTool; | 12 | import com.xly.tool.InteractionTool; |
| 16 | import com.xly.tool.KgQueryTool; | 13 | import com.xly.tool.KgQueryTool; |
| 17 | import com.xly.tool.ProposeWriteTool; | 14 | import com.xly.tool.ProposeWriteTool; |
| 18 | -import com.xly.tool.QueryTool; | ||
| 19 | -import com.xly.tool.SkillTool; | ||
| 20 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; | 15 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; |
| 21 | -import dev.langchain4j.model.chat.ChatModel; | ||
| 22 | import dev.langchain4j.model.chat.StreamingChatModel; | 16 | import dev.langchain4j.model.chat.StreamingChatModel; |
| 23 | import dev.langchain4j.service.AiServices; | 17 | import dev.langchain4j.service.AiServices; |
| 24 | import org.springframework.beans.factory.annotation.Qualifier; | 18 | import org.springframework.beans.factory.annotation.Qualifier; |
| @@ -30,16 +24,16 @@ import org.springframework.stereotype.Component; | @@ -30,16 +24,16 @@ import org.springframework.stereotype.Component; | ||
| 30 | * | 24 | * |
| 31 | * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, | 25 | * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, |
| 32 | * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 | 26 | * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 |
| 33 | - * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。 | 27 | + * 工具实例里(ErpReadTool/ProposeWriteTool/FormCollectTool),从根上保证鉴权与 token 正确。 |
| 34 | * | 28 | * |
| 35 | - * <p>无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、 | ||
| 36 | - * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。 | 29 | + * <p>工具集**固定 6 个**(findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser), |
| 30 | + * 不再按意图分范围:n=50 基准显示分范围收不到准确率收益,反而破坏 KV-cache 前缀稳定性。 | ||
| 31 | + * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。 | ||
| 37 | */ | 32 | */ |
| 38 | @Component | 33 | @Component |
| 39 | public class AgentFactory { | 34 | public class AgentFactory { |
| 40 | 35 | ||
| 41 | private final StreamingChatModel streamingModel; | 36 | private final StreamingChatModel streamingModel; |
| 42 | - private final ChatModel sqlModel; | ||
| 43 | private final RedisChatMemoryStore memoryStore; | 37 | private final RedisChatMemoryStore memoryStore; |
| 44 | private final SystemPromptService systemPromptService; | 38 | private final SystemPromptService systemPromptService; |
| 45 | 39 | ||
| @@ -48,22 +42,16 @@ public class AgentFactory { | @@ -48,22 +42,16 @@ public class AgentFactory { | ||
| 48 | private final FormResolverService resolver; | 42 | private final FormResolverService resolver; |
| 49 | private final OpService ops; | 43 | private final OpService ops; |
| 50 | private final ObjectMapper mapper; | 44 | private final ObjectMapper mapper; |
| 51 | - private final AuditService audit; | ||
| 52 | - private final SkillService skillService; | ||
| 53 | 45 | ||
| 54 | private final KgQueryTool kgQueryTool; | 46 | private final KgQueryTool kgQueryTool; |
| 55 | - private final SkillTool skillTool; | ||
| 56 | private final InteractionTool interactionTool; | 47 | private final InteractionTool interactionTool; |
| 57 | 48 | ||
| 58 | public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, | 49 | public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, |
| 59 | - @Qualifier("sqlChatModel") ChatModel sqlModel, | ||
| 60 | RedisChatMemoryStore memoryStore, | 50 | RedisChatMemoryStore memoryStore, |
| 61 | SystemPromptService systemPromptService, | 51 | SystemPromptService systemPromptService, |
| 62 | ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, | 52 | ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, |
| 63 | - ObjectMapper mapper, AuditService audit, SkillService skillService, | ||
| 64 | - KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) { | 53 | + ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) { |
| 65 | this.streamingModel = streamingModel; | 54 | this.streamingModel = streamingModel; |
| 66 | - this.sqlModel = sqlModel; | ||
| 67 | this.memoryStore = memoryStore; | 55 | this.memoryStore = memoryStore; |
| 68 | this.systemPromptService = systemPromptService; | 56 | this.systemPromptService = systemPromptService; |
| 69 | this.erp = erp; | 57 | this.erp = erp; |
| @@ -71,45 +59,19 @@ public class AgentFactory { | @@ -71,45 +59,19 @@ public class AgentFactory { | ||
| 71 | this.resolver = resolver; | 59 | this.resolver = resolver; |
| 72 | this.ops = ops; | 60 | this.ops = ops; |
| 73 | this.mapper = mapper; | 61 | this.mapper = mapper; |
| 74 | - this.audit = audit; | ||
| 75 | - this.skillService = skillService; | ||
| 76 | this.kgQueryTool = kgQueryTool; | 62 | this.kgQueryTool = kgQueryTool; |
| 77 | - this.skillTool = skillTool; | ||
| 78 | this.interactionTool = interactionTool; | 63 | this.interactionTool = interactionTool; |
| 79 | } | 64 | } |
| 80 | 65 | ||
| 81 | - /** 兜底:全部工具(意图门失败时用)。 */ | ||
| 82 | - public ReActAgent build(AgentIdentity identity) { | ||
| 83 | - return build(identity, ToolScope.FULL); | ||
| 84 | - } | ||
| 85 | - | ||
| 86 | /** | 66 | /** |
| 87 | - * 按意图范围组装 ReAct agent:只暴露该范围相关的工具 + 对应版本的 system prompt。 | ||
| 88 | - * | ||
| 89 | - * <p>收窄可见工具(3-5 个)显著提升弱模型的选工具准确率;{@code maxSequentialToolsInvocations} | 67 | + * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations} |
| 90 | * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 | 68 | * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 |
| 91 | */ | 69 | */ |
| 92 | - public ReActAgent build(AgentIdentity identity, ToolScope scope) { | ||
| 93 | - String systemPrompt = systemPromptService.buildPrompt(scope); | ||
| 94 | - | ||
| 95 | - ErpReadTool readTool = new ErpReadTool(erp, jdbc, resolver, identity); | ||
| 96 | - Object[] tools; | ||
| 97 | - switch (scope) { | ||
| 98 | - case READ: | ||
| 99 | - tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, | ||
| 100 | - new QueryTool(sqlModel, jdbc, audit, identity, resolver)}; | ||
| 101 | - break; | ||
| 102 | - case WRITE: | ||
| 103 | - tools = new Object[]{skillTool, interactionTool, readTool, | ||
| 104 | - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), | ||
| 105 | - new FormCollectTool(jdbc, resolver, identity, mapper)}; | ||
| 106 | - break; | ||
| 107 | - default: // FULL | ||
| 108 | - tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, | ||
| 109 | - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), | ||
| 110 | - new QueryTool(sqlModel, jdbc, audit, identity, resolver), | ||
| 111 | - new FormCollectTool(jdbc, resolver, identity, mapper)}; | ||
| 112 | - } | 70 | + public ReActAgent build(AgentIdentity identity) { |
| 71 | + Object[] tools = new Object[]{kgQueryTool, interactionTool, | ||
| 72 | + new ErpReadTool(erp, resolver, identity), | ||
| 73 | + new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), | ||
| 74 | + new FormCollectTool(jdbc, resolver, identity, mapper)}; | ||
| 113 | 75 | ||
| 114 | return AiServices.builder(ReActAgent.class) | 76 | return AiServices.builder(ReActAgent.class) |
| 115 | .streamingChatModel(streamingModel) | 77 | .streamingChatModel(streamingModel) |
| @@ -120,7 +82,7 @@ public class AgentFactory { | @@ -120,7 +82,7 @@ public class AgentFactory { | ||
| 120 | .maxMessages(30) | 82 | .maxMessages(30) |
| 121 | .chatMemoryStore(memoryStore) | 83 | .chatMemoryStore(memoryStore) |
| 122 | .build()) | 84 | .build()) |
| 123 | - .systemMessageProvider(memoryId -> systemPrompt) | 85 | + .systemMessageProvider(memoryId -> systemPromptService.prompt()) |
| 124 | .build(); | 86 | .build(); |
| 125 | } | 87 | } |
| 126 | 88 |
src/main/java/com/xly/config/ModelConfig.java
| @@ -3,44 +3,13 @@ package com.xly.config; | @@ -3,44 +3,13 @@ package com.xly.config; | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper; | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | import com.fasterxml.jackson.databind.SerializationFeature; | 4 | import com.fasterxml.jackson.databind.SerializationFeature; |
| 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; | 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; |
| 6 | -import dev.langchain4j.model.chat.ChatModel; | ||
| 7 | -import dev.langchain4j.model.openai.OpenAiChatModel; | ||
| 8 | -import org.springframework.beans.factory.annotation.Value; | ||
| 9 | import org.springframework.context.annotation.Bean; | 6 | import org.springframework.context.annotation.Bean; |
| 10 | import org.springframework.context.annotation.Configuration; | 7 | import org.springframework.context.annotation.Configuration; |
| 11 | import org.springframework.context.annotation.Primary; | 8 | import org.springframework.context.annotation.Primary; |
| 12 | 9 | ||
| 13 | -import java.time.Duration; | ||
| 14 | -import java.util.List; | ||
| 15 | - | ||
| 16 | @Configuration | 10 | @Configuration |
| 17 | public class ModelConfig { | 11 | public class ModelConfig { |
| 18 | 12 | ||
| 19 | - @Value("${llm.base-url}") | ||
| 20 | - private String baseUrl; | ||
| 21 | - | ||
| 22 | - @Value("${llm.api-key:ollama}") | ||
| 23 | - private String apiKey; | ||
| 24 | - | ||
| 25 | - @Value("${llm.sql-model}") | ||
| 26 | - private String sqlModelName; | ||
| 27 | - | ||
| 28 | - /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */ | ||
| 29 | - @Bean("sqlChatModel") | ||
| 30 | - public ChatModel sqlChatModel(TracingChatModelListener tracingListener) { | ||
| 31 | - return OpenAiChatModel.builder() | ||
| 32 | - .baseUrl(baseUrl) | ||
| 33 | - .apiKey(apiKey) | ||
| 34 | - .modelName(sqlModelName) | ||
| 35 | - .temperature(0.0) | ||
| 36 | - .topP(0.95) | ||
| 37 | - .reasoningEffort("none") | ||
| 38 | - .listeners(List.of(tracingListener)) | ||
| 39 | - .timeout(Duration.ofSeconds(120)) | ||
| 40 | - .maxRetries(3) | ||
| 41 | - .build(); | ||
| 42 | - } | ||
| 43 | - | ||
| 44 | @Bean | 13 | @Bean |
| 45 | @Primary | 14 | @Primary |
| 46 | public ObjectMapper objectMapper() { | 15 | public ObjectMapper objectMapper() { |
src/main/java/com/xly/service/FormResolverService.java
| @@ -83,6 +83,15 @@ public class FormResolverService { | @@ -83,6 +83,15 @@ public class FormResolverService { | ||
| 83 | "ORDER BY iFormUses DESC LIMIT 1", table); | 83 | "ORDER BY iFormUses DESC LIMIT 1", table); |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | + /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */ | ||
| 87 | + public String resolveNameFieldByFormId(String formId) { | ||
| 88 | + return queryOne( | ||
| 89 | + "SELECT fd.sField FROM viw_ai_useful_forms af " + | ||
| 90 | + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | ||
| 91 | + "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + | ||
| 92 | + "ORDER BY fd.iFormUses DESC LIMIT 1", formId); | ||
| 93 | + } | ||
| 94 | + | ||
| 86 | // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。 | 95 | // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。 |
| 87 | private static final Set<String> SYS_EXACT = Set.of( | 96 | private static final Set<String> SYS_EXACT = Set.of( |
| 88 | "sId", "sBrandsId", "sSubsidiaryId", "sMakePerson", "sFormId", "sBillNo", "iIncrement", "iOrder", | 97 | "sId", "sBrandsId", "sSubsidiaryId", "sMakePerson", "sFormId", "sBillNo", "iIncrement", "iOrder", |
src/main/java/com/xly/service/IntentService.java
| @@ -11,14 +11,14 @@ import java.util.List; | @@ -11,14 +11,14 @@ import java.util.List; | ||
| 11 | import java.util.Map; | 11 | import java.util.Map; |
| 12 | 12 | ||
| 13 | /** | 13 | /** |
| 14 | - * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 | 14 | + * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图(4 类), 单据类型, 带角色的实体, 缺失信息}。 |
| 15 | * | 15 | * |
| 16 | - * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下 | ||
| 17 | - * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」 | ||
| 18 | - * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的 | ||
| 19 | - * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。 | 16 | + * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。提示只给**类定义** |
| 17 | + * (含有界的行业常识,如 定制品问价=新建报价),不写逐案纠错规则——错误种类无穷, | ||
| 18 | + * 按案例打补丁写不完;观察到的错例进回归基准(bench50)而不是提示。 | ||
| 19 | + * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 | ||
| 20 | * | 20 | * |
| 21 | - * <p>失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。 | 21 | + * <p>失败(模型不可达/JSON 异常)时返回 intent=其他 的空结果,调用方降级到通用 agent,绝不中断。 |
| 22 | */ | 22 | */ |
| 23 | @Service | 23 | @Service |
| 24 | public class IntentService { | 24 | public class IntentService { |
| @@ -26,18 +26,17 @@ public class IntentService { | @@ -26,18 +26,17 @@ public class IntentService { | ||
| 26 | private static final Logger log = LoggerFactory.getLogger(IntentService.class); | 26 | private static final Logger log = LoggerFactory.getLogger(IntentService.class); |
| 27 | 27 | ||
| 28 | private static final String SYSTEM = | 28 | private static final String SYSTEM = |
| 29 | - "你是印刷/包装 ERP 的意图与实体抽取器。给你一句用户话,判断其操作意图" | ||
| 30 | - + "(查询/新增/修改/删除/审核/闲聊/不清楚)、涉及的单据或实体类型,并抽取句中出现的实体及其业务角色" | ||
| 31 | - + "(客户/产品/物料/供应商/数量/尺寸/日期/金额)。规则:" | ||
| 32 | - + "①『报价X』『给X报价』里,若 X 是物品(如 纸盒/彩盒/画册/小册子)则 X 是**产品**,不是客户;" | ||
| 33 | - + "只有明确出现公司/客户名(如『给苏州华为报价』)时才把它当**客户**。" | ||
| 34 | - + "②对某个产品/规格问价——『多少钱』『什么价』『报个价』『报价』『价格是多少』——在印刷/包装行业" | ||
| 35 | - + "**通常是要新建一张报价单**(价格由系统核价算出,系统里没有该定制活的现成价),intent=**新增**、单据=**报价**;" | ||
| 36 | - + "只有明确查看/统计**已存在**的单据(给了单号,或说『查已有报价/列出报价单/上月报价额』)才是查询。" | ||
| 37 | - + "③『有多少X/查一下X/X的电话是多少』这类问既有数据是**查询**,不是新增。" | ||
| 38 | - + "④只说一个名词(如『报价』『纸盒』)而无动作时,intent=不清楚。" | ||
| 39 | - + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。" | ||
| 40 | - + "只输出 JSON,不要解释。"; | 29 | + "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n" |
| 30 | + + "意图定义:\n" | ||
| 31 | + + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n" | ||
| 32 | + + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)" | ||
| 33 | + + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n" | ||
| 34 | + + "- 操作已有单据:对系统里已存在的某条单据/记录做修改、作废/删除、审核/反审核、复原。\n" | ||
| 35 | + + "- 其他:闲聊、问候、与 ERP 业务无关,或信息太少无法判断(如只说一个名词)。\n" | ||
| 36 | + + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n" | ||
| 37 | + + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价)," | ||
| 38 | + + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。" | ||
| 39 | + + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。"; | ||
| 41 | 40 | ||
| 42 | private final LlmJsonClient llm; | 41 | private final LlmJsonClient llm; |
| 43 | 42 | ||
| @@ -45,7 +44,7 @@ public class IntentService { | @@ -45,7 +44,7 @@ public class IntentService { | ||
| 45 | this.llm = llm; | 44 | this.llm = llm; |
| 46 | } | 45 | } |
| 47 | 46 | ||
| 48 | - /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */ | 47 | + /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ |
| 49 | public Intent classify(String utterance) { | 48 | public Intent classify(String utterance) { |
| 50 | Intent out = new Intent(); | 49 | Intent out = new Intent(); |
| 51 | if (utterance == null || utterance.isBlank()) { | 50 | if (utterance == null || utterance.isBlank()) { |
| @@ -53,10 +52,10 @@ public class IntentService { | @@ -53,10 +52,10 @@ public class IntentService { | ||
| 53 | } | 52 | } |
| 54 | JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); | 53 | JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); |
| 55 | if (n == null) { | 54 | if (n == null) { |
| 56 | - log.warn("intent classify fell back to UNCLEAR (model unavailable)"); | 55 | + log.warn("intent classify fell back to 其他 (model unavailable)"); |
| 57 | return out; | 56 | return out; |
| 58 | } | 57 | } |
| 59 | - String intent = n.path("intent").asText(Intent.UNCLEAR); | 58 | + String intent = n.path("intent").asText(Intent.OTHER); |
| 60 | out.intent = normalizeIntent(intent); | 59 | out.intent = normalizeIntent(intent); |
| 61 | out.danju = n.path("danju").asText(""); | 60 | out.danju = n.path("danju").asText(""); |
| 62 | JsonNode es = n.path("entities"); | 61 | JsonNode es = n.path("entities"); |
| @@ -117,19 +116,16 @@ public class IntentService { | @@ -117,19 +116,16 @@ public class IntentService { | ||
| 117 | } | 116 | } |
| 118 | 117 | ||
| 119 | private static String normalizeIntent(String s) { | 118 | private static String normalizeIntent(String s) { |
| 120 | - if (s == null) return Intent.UNCLEAR; | 119 | + if (s == null) return Intent.OTHER; |
| 121 | s = s.trim(); | 120 | s = s.trim(); |
| 122 | switch (s) { | 121 | switch (s) { |
| 123 | case Intent.QUERY: | 122 | case Intent.QUERY: |
| 124 | case Intent.CREATE: | 123 | case Intent.CREATE: |
| 125 | - case Intent.UPDATE: | ||
| 126 | - case Intent.DELETE: | ||
| 127 | - case Intent.EXAMINE: | ||
| 128 | - case Intent.CHAT: | ||
| 129 | - case Intent.UNCLEAR: | 124 | + case Intent.OPERATE: |
| 125 | + case Intent.OTHER: | ||
| 130 | return s; | 126 | return s; |
| 131 | default: | 127 | default: |
| 132 | - return Intent.UNCLEAR; | 128 | + return Intent.OTHER; |
| 133 | } | 129 | } |
| 134 | } | 130 | } |
| 135 | 131 | ||
| @@ -145,7 +141,7 @@ public class IntentService { | @@ -145,7 +141,7 @@ public class IntentService { | ||
| 145 | 141 | ||
| 146 | Map<String, Object> props = new LinkedHashMap<>(); | 142 | Map<String, Object> props = new LinkedHashMap<>(); |
| 147 | props.put("intent", Map.of("type", "string", | 143 | props.put("intent", Map.of("type", "string", |
| 148 | - "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚"))); | 144 | + "enum", List.of("查询", "新增", "操作已有单据", "其他"))); |
| 149 | props.put("danju", Map.of("type", "string")); | 145 | props.put("danju", Map.of("type", "string")); |
| 150 | props.put("entities", Map.of("type", "array", "items", entityItem)); | 146 | props.put("entities", Map.of("type", "array", "items", entityItem)); |
| 151 | props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); | 147 | props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); |
src/main/java/com/xly/service/SkillService.java deleted
| 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
| 1 | package com.xly.service; | 1 | package com.xly.service; |
| 2 | 2 | ||
| 3 | -import com.xly.agent.ToolScope; | ||
| 4 | import org.springframework.jdbc.core.JdbcTemplate; | 3 | import org.springframework.jdbc.core.JdbcTemplate; |
| 5 | import org.springframework.stereotype.Service; | 4 | import org.springframework.stereotype.Service; |
| 6 | 5 | ||
| @@ -8,25 +7,22 @@ import java.util.List; | @@ -8,25 +7,22 @@ import java.util.List; | ||
| 8 | import java.util.Map; | 7 | import java.util.Map; |
| 9 | 8 | ||
| 10 | /** | 9 | /** |
| 11 | - * 构建 agent 的 system prompt——**按工具范围(ToolScope)分版**。 | 10 | + * 构建 agent 的**唯一** system prompt。 |
| 12 | * | 11 | * |
| 13 | - * <p>新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。 | ||
| 14 | - * 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。 | ||
| 15 | - * 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。 | 12 | + * <p>所有轮次共用同一份提示 + 同一组 6 个工具(findForms/readFormData/lookupRecord/ |
| 13 | + * collectForm/proposeWrite/askUser):n=50 基准显示按意图分版收不到准确率收益, | ||
| 14 | + * 反而破坏 KV-cache 前缀稳定性。提示遵循弱模型提示工程:关键规则前置、正向表述、 | ||
| 15 | + * 工具清单短;域知识(业务域地图、业务常识)常驻,不做按类分版。 | ||
| 16 | */ | 16 | */ |
| 17 | @Service | 17 | @Service |
| 18 | public class SystemPromptService { | 18 | public class SystemPromptService { |
| 19 | 19 | ||
| 20 | private final JdbcTemplate jdbc; | 20 | private final JdbcTemplate jdbc; |
| 21 | - private final SkillService skills; | ||
| 22 | 21 | ||
| 23 | - private volatile String readPrompt; | ||
| 24 | - private volatile String writePrompt; | ||
| 25 | - private volatile String fullPrompt; | 22 | + private volatile String prompt; |
| 26 | 23 | ||
| 27 | - public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { | 24 | + public SystemPromptService(JdbcTemplate jdbc) { |
| 28 | this.jdbc = jdbc; | 25 | this.jdbc = jdbc; |
| 29 | - this.skills = skills; | ||
| 30 | } | 26 | } |
| 31 | 27 | ||
| 32 | private static final String HEADER = | 28 | private static final String HEADER = |
| @@ -34,93 +30,38 @@ public class SystemPromptService { | @@ -34,93 +30,38 @@ public class SystemPromptService { | ||
| 34 | + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" | 30 | + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" |
| 35 | + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; | 31 | + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; |
| 36 | 32 | ||
| 37 | - public String buildPrompt(ToolScope scope) { | ||
| 38 | - switch (scope) { | ||
| 39 | - case READ: | ||
| 40 | - if (readPrompt == null) readPrompt = renderRead(); | ||
| 41 | - return readPrompt; | ||
| 42 | - case WRITE: | ||
| 43 | - if (writePrompt == null) writePrompt = renderWrite(); | ||
| 44 | - return writePrompt; | ||
| 45 | - default: | ||
| 46 | - if (fullPrompt == null) fullPrompt = renderFull(); | ||
| 47 | - return fullPrompt; | 33 | + public String prompt() { |
| 34 | + String p = prompt; | ||
| 35 | + if (p == null) { | ||
| 36 | + p = render(); | ||
| 37 | + prompt = p; | ||
| 48 | } | 38 | } |
| 39 | + return p; | ||
| 49 | } | 40 | } |
| 50 | 41 | ||
| 51 | - private String renderRead() { | 42 | + private String render() { |
| 52 | return HEADER + """ | 43 | return HEADER + """ |
| 53 | 44 | ||
| 54 | - 本轮任务是**查询 / 读取**数据(不做任何写操作)。 | ||
| 55 | - | ||
| 56 | 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 | 45 | 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 |
| 57 | %s | 46 | %s |
| 58 | - 【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做: | ||
| 59 | - %s | 47 | + 【业务常识】 |
| 48 | + - 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。 | ||
| 49 | + - 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。 | ||
| 50 | + | ||
| 60 | 【可用工具(只有这些)】 | 51 | 【可用工具(只有这些)】 |
| 61 | - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 | 52 | - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 62 | - - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。 | ||
| 63 | - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 | 53 | - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 |
| 64 | - - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。 | ||
| 65 | - - queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。 | ||
| 66 | - - loadSkill(name)、askUser(question, options?)。 | ||
| 67 | - | ||
| 68 | - 【准则】 | ||
| 69 | - 1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。 | ||
| 70 | - 2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。 | ||
| 71 | - 3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。 | ||
| 72 | - """.formatted(renderDomainMap(), renderSkills()); | ||
| 73 | - } | ||
| 74 | - | ||
| 75 | - private String renderWrite() { | ||
| 76 | - return HEADER + """ | ||
| 77 | - | ||
| 78 | - 本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、 | ||
| 79 | - 绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。 | ||
| 80 | - | ||
| 81 | - 【可用工具(只有这些)】 | ||
| 82 | - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。 | ||
| 83 | - - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作: | ||
| 84 | - · create=新增(用 fieldsJson,通常在用户表单提交后) | ||
| 85 | - · update=改某字段(用 recordKeyword+fieldChinese+newValue) | ||
| 86 | - · invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废 | ||
| 87 | - · examine=审核;cancelExamine=销审/反审核 | ||
| 88 | - · delete=物理删除(明细行/极少用,业务单据别用) | ||
| 89 | - 它自行定位主表与记录,无需先 findForms。 | ||
| 90 | - - lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。 | ||
| 91 | - - askUser(question, options?)、loadSkill(name)。 | 54 | + - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 |
| 55 | + - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 | ||
| 56 | + - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 | ||
| 57 | + - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 | ||
| 92 | 58 | ||
| 93 | 【准则】 | 59 | 【准则】 |
| 94 | - 1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。 | ||
| 95 | - 2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。 | ||
| 96 | - 3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。 | ||
| 97 | - """.formatted(); | ||
| 98 | - } | ||
| 99 | - | ||
| 100 | - private String renderFull() { | ||
| 101 | - // 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。 | ||
| 102 | - return HEADER + """ | ||
| 103 | - | ||
| 104 | - 【业务域地图】 | ||
| 105 | - %s | ||
| 106 | - 【可用技能】命中「何时用」时先 loadSkill(技能名): | ||
| 107 | - %s | ||
| 108 | - 【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData; | ||
| 109 | - 写(人在环,仅提议):collectForm,以及唯一的写工具 | ||
| 110 | - proposeWrite(action=create|update|invalid|cancelInvalid|examine|cancelExamine|delete, ...); | ||
| 111 | - 以及 askUser/loadSkill。 | ||
| 112 | - | ||
| 113 | - 【准则】 | ||
| 114 | - 1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。 | ||
| 115 | - 2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm,用户提交后再 proposeWrite(action=create)。删除业务单据用 action=invalid(作废),不要用 delete。 | ||
| 116 | - 3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。 | ||
| 117 | - 4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。 | ||
| 118 | - """.formatted(renderDomainMap(), renderSkills()); | ||
| 119 | - } | ||
| 120 | - | ||
| 121 | - private String renderSkills() { | ||
| 122 | - String digest = skills.promptDigest(); | ||
| 123 | - return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest; | 60 | + 1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。 |
| 61 | + 2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。 | ||
| 62 | + 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。 | ||
| 63 | + 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。 | ||
| 64 | + """.formatted(renderDomainMap()); | ||
| 124 | } | 65 | } |
| 125 | 66 | ||
| 126 | private String renderDomainMap() { | 67 | private String renderDomainMap() { |
src/main/java/com/xly/tool/ErpReadTool.java
| @@ -6,10 +6,8 @@ import com.xly.service.ErpClient; | @@ -6,10 +6,8 @@ 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; | ||
| 10 | 9 | ||
| 11 | import java.util.ArrayList; | 10 | import java.util.ArrayList; |
| 12 | -import java.util.HashMap; | ||
| 13 | import java.util.Iterator; | 11 | import java.util.Iterator; |
| 14 | import java.util.List; | 12 | import java.util.List; |
| 15 | import java.util.Map; | 13 | import java.util.Map; |
| @@ -32,13 +30,11 @@ public class ErpReadTool { | @@ -32,13 +30,11 @@ public class ErpReadTool { | ||
| 32 | private static final int MAX_COLS = 6; | 30 | private static final int MAX_COLS = 6; |
| 33 | 31 | ||
| 34 | private final ErpClient erp; | 32 | private final ErpClient erp; |
| 35 | - private final JdbcTemplate jdbc; | ||
| 36 | private final FormResolverService resolver; | 33 | private final FormResolverService resolver; |
| 37 | private final AgentIdentity identity; | 34 | private final AgentIdentity identity; |
| 38 | 35 | ||
| 39 | - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity) { | 36 | + public ErpReadTool(ErpClient erp, FormResolverService resolver, AgentIdentity identity) { |
| 40 | this.erp = erp; | 37 | this.erp = erp; |
| 41 | - this.jdbc = jdbc; | ||
| 42 | this.resolver = resolver; | 38 | this.resolver = resolver; |
| 43 | this.identity = identity; | 39 | this.identity = identity; |
| 44 | } | 40 | } |
| @@ -129,7 +125,7 @@ public class ErpReadTool { | @@ -129,7 +125,7 @@ public class ErpReadTool { | ||
| 129 | return "你没有访问该表单的权限。"; | 125 | return "你没有访问该表单的权限。"; |
| 130 | } | 126 | } |
| 131 | String kw = (keyword == null) ? "" : keyword.trim(); | 127 | String kw = (keyword == null) ? "" : keyword.trim(); |
| 132 | - String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim()); | 128 | + String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); |
| 133 | 129 | ||
| 134 | JsonNode root; | 130 | JsonNode root; |
| 135 | try { | 131 | try { |
| @@ -151,7 +147,7 @@ public class ErpReadTool { | @@ -151,7 +147,7 @@ public class ErpReadTool { | ||
| 151 | return "该表单当前没有数据(共 " + total + " 条)。"; | 147 | return "该表单当前没有数据(共 " + total + " 条)。"; |
| 152 | } | 148 | } |
| 153 | 149 | ||
| 154 | - Map<String, String> labels = loadLabels(formId.trim()); | 150 | + Map<String, String> labels = resolver.fieldLabels(formId.trim()); |
| 155 | List<String> cols = pickColumns(data.get(0), labels); | 151 | List<String> cols = pickColumns(data.get(0), labels); |
| 156 | 152 | ||
| 157 | String scope; | 153 | String scope; |
| @@ -179,45 +175,6 @@ public class ErpReadTool { | @@ -179,45 +175,6 @@ public class ErpReadTool { | ||
| 179 | return sb.toString(); | 175 | return sb.toString(); |
| 180 | } | 176 | } |
| 181 | 177 | ||
| 182 | - /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ | ||
| 183 | - private Map<String, String> loadLabels(String formId) { | ||
| 184 | - Map<String, String> m = new HashMap<>(); | ||
| 185 | - try { | ||
| 186 | - List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 187 | - "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh " + | ||
| 188 | - "FROM viw_ai_useful_forms af " + | ||
| 189 | - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | ||
| 190 | - "WHERE af.sFormId = ? GROUP BY fd.sField", formId); | ||
| 191 | - for (Map<String, Object> r : rows) { | ||
| 192 | - Object f = r.get("f"); | ||
| 193 | - Object zh = r.get("zh"); | ||
| 194 | - if (f != null && zh != null) { | ||
| 195 | - m.put(f.toString(), zh.toString()); | ||
| 196 | - } | ||
| 197 | - } | ||
| 198 | - } catch (Exception ignore) { | ||
| 199 | - // 无字典时退化为技术列名 | ||
| 200 | - } | ||
| 201 | - return m; | ||
| 202 | - } | ||
| 203 | - | ||
| 204 | - /** 猜测该表单的"名称"字段(用于按关键词过滤):数据源表里以 Name 结尾、使用最广的字段。 */ | ||
| 205 | - private String resolveNameField(String formId) { | ||
| 206 | - try { | ||
| 207 | - List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 208 | - "SELECT fd.sField AS f FROM viw_ai_useful_forms af " + | ||
| 209 | - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | ||
| 210 | - "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + | ||
| 211 | - "ORDER BY fd.iFormUses DESC LIMIT 1", formId); | ||
| 212 | - if (!rows.isEmpty() && rows.get(0).get("f") != null) { | ||
| 213 | - return rows.get(0).get("f").toString(); | ||
| 214 | - } | ||
| 215 | - } catch (Exception ignore) { | ||
| 216 | - // 无字典时不过滤 | ||
| 217 | - } | ||
| 218 | - return null; | ||
| 219 | - } | ||
| 220 | - | ||
| 221 | /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ | 178 | /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ |
| 222 | private List<String> pickColumns(JsonNode first, Map<String, String> labels) { | 179 | private List<String> pickColumns(JsonNode first, Map<String, String> labels) { |
| 223 | List<String> labeled = new ArrayList<>(); | 180 | List<String> labeled = new ArrayList<>(); |
src/main/java/com/xly/tool/KgQueryTool.java
| @@ -9,13 +9,8 @@ import java.util.List; | @@ -9,13 +9,8 @@ import java.util.List; | ||
| 9 | import java.util.Map; | 9 | import java.util.Map; |
| 10 | 10 | ||
| 11 | /** | 11 | /** |
| 12 | - * 知识图谱查询工具(架构 §5 #3 KgSearch / §11)。两个只读能力: | ||
| 13 | - * <ul> | ||
| 14 | - * <li>{@link #findForms} —— 按关键词检索 ERP 业务表单目录(视图 {@code viw_ai_useful_forms}, | ||
| 15 | - * 1748 张有用业务表单),即「表单名 → formId/moduleId」定位;</li> | ||
| 16 | - * <li>{@link #kgSearch} —— L2 单据流转/相邻单据({@code viw_kg_form_neighbors})与 | ||
| 17 | - * L3 字段→表/列({@code viw_kg_field_dict}),按词查询、结果截断,绝不整表 dump。</li> | ||
| 18 | - * </ul> | 12 | + * 表单目录检索工具:{@link #findForms} 按关键词检索 ERP 业务表单目录 |
| 13 | + * (视图 {@code viw_ai_useful_forms},1748 张有用业务表单),即「表单名 → formId/moduleId」定位。 | ||
| 19 | * | 14 | * |
| 20 | * <p>表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。 | 15 | * <p>表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。 |
| 21 | */ | 16 | */ |
| @@ -67,79 +62,6 @@ public class KgQueryTool { | @@ -67,79 +62,6 @@ public class KgQueryTool { | ||
| 67 | return sb.toString(); | 62 | return sb.toString(); |
| 68 | } | 63 | } |
| 69 | 64 | ||
| 70 | - @Tool("查知识图谱(KG):某表单的**上下游流转/相邻单据**(L2)与某字段**在哪些表/列**(L3)。" | ||
| 71 | - + "用于弄清「这张单从哪来、到哪去、和谁相关」或「某个字段落在哪张表」。入参 = 表单名或字段中文名关键词。") | ||
| 72 | - public String kgSearch(@P("表单名或字段中文名关键词,如 采购订单 / 送货 / 单价 / 数量") String keyword) { | ||
| 73 | - if (keyword == null || keyword.isBlank()) { | ||
| 74 | - return "请提供一个表单名或字段中文名关键词。"; | ||
| 75 | - } | ||
| 76 | - String kw = keyword.trim(); | ||
| 77 | - String like = "%" + kw + "%"; | ||
| 78 | - StringBuilder sb = new StringBuilder(); | ||
| 79 | - | ||
| 80 | - // L2:表单邻居 / 上下游流转 | ||
| 81 | - List<Map<String, Object>> flow = safeQuery( | ||
| 82 | - "SELECT sFormTitle, sDomain, iUpstream, iDownstream, sUpForms, sDownForms, sRefTables " + | ||
| 83 | - "FROM viw_kg_form_neighbors WHERE sFormTitle LIKE ? " + | ||
| 84 | - "ORDER BY (COALESCE(iUpstream,0)+COALESCE(iDownstream,0)) DESC LIMIT 5", like); | ||
| 85 | - if (!flow.isEmpty()) { | ||
| 86 | - sb.append("【单据流转 / 相邻单据】\n"); | ||
| 87 | - for (Map<String, Object> r : flow) { | ||
| 88 | - sb.append("- ").append(str(r.get("sFormTitle"))) | ||
| 89 | - .append("(域:").append(str(r.get("sDomain"))).append(")"); | ||
| 90 | - String up = clip(str(r.get("sUpForms")), 80); | ||
| 91 | - String down = clip(str(r.get("sDownForms")), 80); | ||
| 92 | - if (!up.isBlank()) sb.append("\n 上游←:").append(up); | ||
| 93 | - if (!down.isBlank()) sb.append("\n 下游→:").append(down); | ||
| 94 | - String ref = clip(str(r.get("sRefTables")), 80); | ||
| 95 | - if (!ref.isBlank()) sb.append("\n 引用表:").append(ref); | ||
| 96 | - sb.append('\n'); | ||
| 97 | - } | ||
| 98 | - } | ||
| 99 | - | ||
| 100 | - // L3:字段 -> 表/列(按词查,绝不整表 dump) | ||
| 101 | - List<Map<String, Object>> fields = safeQuery( | ||
| 102 | - "SELECT sTable, sField, sChinese, sFkTable FROM viw_kg_field_dict " + | ||
| 103 | - "WHERE sChinese LIKE ? AND sTable NOT LIKE 'viw%' " + | ||
| 104 | - "ORDER BY iFormUses DESC LIMIT 8", like); | ||
| 105 | - if (!fields.isEmpty()) { | ||
| 106 | - sb.append("\n【字段所在表/列】\n"); | ||
| 107 | - for (Map<String, Object> r : fields) { | ||
| 108 | - sb.append("- ").append(str(r.get("sChinese"))) | ||
| 109 | - .append(" = ").append(str(r.get("sTable"))).append(".").append(str(r.get("sField"))); | ||
| 110 | - String fk = str(r.get("sFkTable")); | ||
| 111 | - if (fk != null && !fk.isBlank() && !"null".equalsIgnoreCase(fk)) { | ||
| 112 | - sb.append("(外键→").append(fk).append(")"); | ||
| 113 | - } | ||
| 114 | - sb.append('\n'); | ||
| 115 | - } | ||
| 116 | - } | ||
| 117 | - | ||
| 118 | - if (sb.length() == 0) { | ||
| 119 | - return "知识图谱里没有与「" + kw + "」直接相关的流转或字段。可以换个更常见的单据名或字段名。"; | ||
| 120 | - } | ||
| 121 | - return sb.toString(); | ||
| 122 | - } | ||
| 123 | - | ||
| 124 | - private List<Map<String, Object>> safeQuery(String sql, Object... args) { | ||
| 125 | - try { | ||
| 126 | - return jdbc.queryForList(sql, args); | ||
| 127 | - } catch (Exception e) { | ||
| 128 | - return List.of(); | ||
| 129 | - } | ||
| 130 | - } | ||
| 131 | - | ||
| 132 | - private static String clip(String s, int max) { | ||
| 133 | - if (s == null) { | ||
| 134 | - return ""; | ||
| 135 | - } | ||
| 136 | - String t = s.trim(); | ||
| 137 | - if ("null".equalsIgnoreCase(t)) { | ||
| 138 | - return ""; | ||
| 139 | - } | ||
| 140 | - return t.length() > max ? t.substring(0, max) + "…" : t; | ||
| 141 | - } | ||
| 142 | - | ||
| 143 | private static String str(Object o) { | 65 | private static String str(Object o) { |
| 144 | return o == null ? "" : o.toString(); | 66 | return o == null ? "" : o.toString(); |
| 145 | } | 67 | } |
src/main/java/com/xly/tool/ProposeWriteTool.java
| @@ -77,51 +77,13 @@ public class ProposeWriteTool { | @@ -77,51 +77,13 @@ public class ProposeWriteTool { | ||
| 77 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { | 77 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { |
| 78 | return err("缺少实体类型或记录名称。"); | 78 | return err("缺少实体类型或记录名称。"); |
| 79 | } | 79 | } |
| 80 | - Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 81 | - if (form == null) { | ||
| 82 | - return err("找不到「" + entityKeyword + "」对应的可操作主表。"); | ||
| 83 | - } | ||
| 84 | - String formId = str(form.get("sFormId")); | ||
| 85 | - String moduleId = str(form.get("sModuleId")); | ||
| 86 | - String table = str(form.get("sDataSource")); | ||
| 87 | - if (!identity.canAccessModule(moduleId)) { | ||
| 88 | - return err("你没有操作「" + entityKeyword + "」的权限。"); | ||
| 89 | - } | ||
| 90 | - String nameField = queryOne( | ||
| 91 | - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 92 | - "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 93 | - JsonNode root; | ||
| 94 | - try { | ||
| 95 | - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); | ||
| 96 | - } catch (Exception e) { | ||
| 97 | - return err("定位记录失败:" + e.getMessage()); | ||
| 98 | - } | ||
| 99 | - if (root.path("code").asInt(0) < 0) { | ||
| 100 | - return err("定位记录失败:" + root.path("msg").asText("未知错误")); | ||
| 101 | - } | ||
| 102 | - JsonNode rows = root.path("dataset").path("rows"); | ||
| 103 | - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 104 | - int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 105 | - if (n == 0) { | ||
| 106 | - return err("没有找到名称含「" + recordKeyword + "」的记录。"); | ||
| 107 | - } | ||
| 108 | - if (n > 1) { | ||
| 109 | - StringBuilder names = new StringBuilder(); | ||
| 110 | - for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 111 | - if (i > 0) names.append("、"); | ||
| 112 | - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 113 | - } | ||
| 114 | - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); | ||
| 115 | - } | ||
| 116 | - JsonNode rec = data.get(0); | ||
| 117 | - String billId = rec.path("sId").asText(null); | ||
| 118 | - if (isBlank(billId)) { | ||
| 119 | - return err("定位到的记录缺少主键 sId,无法" + verb + "。"); | 80 | + Located l = locateRecord(entityKeyword, recordKeyword, verb); |
| 81 | + if (l.error != null) { | ||
| 82 | + return l.error; | ||
| 120 | } | 83 | } |
| 121 | - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | ||
| 122 | - String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; | 84 | + String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; |
| 123 | // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid | 85 | // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid |
| 124 | - String opId = ops.createDraft(identity.userId(), "invalid", formId, moduleId, table, billId, | 86 | + String opId = ops.createDraft(identity.userId(), "invalid", l.formId, l.moduleId, l.table, l.billId, |
| 125 | null, null, null, cancel ? "cancel" : "toVoid", description); | 87 | null, null, null, cancel ? "cancel" : "toVoid", description); |
| 126 | 88 | ||
| 127 | Map<String, Object> out = new LinkedHashMap<>(); | 89 | Map<String, Object> out = new LinkedHashMap<>(); |
| @@ -138,71 +100,30 @@ public class ProposeWriteTool { | @@ -138,71 +100,30 @@ public class ProposeWriteTool { | ||
| 138 | return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); | 100 | return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); |
| 139 | } | 101 | } |
| 140 | 102 | ||
| 141 | - // 1) 定位可修改的主表(该实体名下、table 类型、最常用的一张) | ||
| 142 | - Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 143 | - if (form == null) { | ||
| 144 | - return err("找不到「" + entityKeyword + "」对应的可修改主表。"); | ||
| 145 | - } | ||
| 146 | - String formId = str(form.get("sFormId")); | ||
| 147 | - String moduleId = str(form.get("sModuleId")); | ||
| 148 | - String table = str(form.get("sDataSource")); | ||
| 149 | - if (!identity.canAccessModule(moduleId)) { | ||
| 150 | - return err("你没有修改「" + entityKeyword + "」的权限。"); | 103 | + // 1) 定位主表 + 唯一记录 |
| 104 | + Located l = locateRecord(entityKeyword, recordKeyword, "修改"); | ||
| 105 | + if (l.error != null) { | ||
| 106 | + return l.error; | ||
| 151 | } | 107 | } |
| 152 | 108 | ||
| 153 | // 2) 字段中文名 -> 技术列名 | 109 | // 2) 字段中文名 -> 技术列名 |
| 154 | String field = queryOne( | 110 | String field = queryOne( |
| 155 | "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", | 111 | "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", |
| 156 | - table, fieldChinese.trim()); | 112 | + l.table, fieldChinese.trim()); |
| 157 | if (field == null) { | 113 | if (field == null) { |
| 158 | field = queryOne( | 114 | field = queryOne( |
| 159 | "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", | 115 | "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", |
| 160 | - table, "%" + fieldChinese.trim() + "%"); | 116 | + l.table, "%" + fieldChinese.trim() + "%"); |
| 161 | } | 117 | } |
| 162 | if (field == null) { | 118 | if (field == null) { |
| 163 | return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); | 119 | return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); |
| 164 | } | 120 | } |
| 121 | + String oldValue = l.rec.path(field).asText(""); | ||
| 165 | 122 | ||
| 166 | - // 3) 定位唯一记录 + 旧值(用名称字段过滤) | ||
| 167 | - String nameField = queryOne( | ||
| 168 | - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 169 | - "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 170 | - JsonNode root; | ||
| 171 | - try { | ||
| 172 | - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); | ||
| 173 | - } catch (Exception e) { | ||
| 174 | - return err("定位记录时读取失败:" + e.getMessage()); | ||
| 175 | - } | ||
| 176 | - if (root.path("code").asInt(0) < 0) { | ||
| 177 | - return err("定位记录失败:" + root.path("msg").asText("未知错误")); | ||
| 178 | - } | ||
| 179 | - JsonNode rows = root.path("dataset").path("rows"); | ||
| 180 | - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 181 | - int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 182 | - if (n == 0) { | ||
| 183 | - return err("没有找到名称含「" + recordKeyword + "」的记录,无法修改。"); | ||
| 184 | - } | ||
| 185 | - if (n > 1) { | ||
| 186 | - StringBuilder names = new StringBuilder(); | ||
| 187 | - for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 188 | - if (i > 0) names.append("、"); | ||
| 189 | - names.append(data.get(i).path(nameField == null ? "" : nameField).asText("")); | ||
| 190 | - } | ||
| 191 | - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只改其中一条。"); | ||
| 192 | - } | ||
| 193 | - | ||
| 194 | - JsonNode rec = data.get(0); | ||
| 195 | - String billId = rec.path("sId").asText(null); | ||
| 196 | - if (isBlank(billId)) { | ||
| 197 | - return err("定位到的记录缺少主键 sId,无法安全修改。"); | ||
| 198 | - } | ||
| 199 | - String oldValue = rec.path(field).asText(""); | ||
| 200 | - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | ||
| 201 | - | ||
| 202 | - // 4) 暂存 draft(不执行) | ||
| 203 | - String description = "将【" + recordName + "】的【" + fieldChinese + "】" | 123 | + // 3) 暂存 draft(不执行) |
| 124 | + String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" | ||
| 204 | + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; | 125 | + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; |
| 205 | - String opId = ops.createDraft(identity.userId(), "update", formId.trim(), moduleId.trim(), table, billId, | 126 | + String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId, |
| 206 | field, fieldChinese, oldValue, newValue, description); | 127 | field, fieldChinese, oldValue, newValue, description); |
| 207 | 128 | ||
| 208 | Map<String, Object> out = new LinkedHashMap<>(); | 129 | Map<String, Object> out = new LinkedHashMap<>(); |
| @@ -218,51 +139,13 @@ public class ProposeWriteTool { | @@ -218,51 +139,13 @@ public class ProposeWriteTool { | ||
| 218 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { | 139 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { |
| 219 | return err("缺少实体类型或记录名称。"); | 140 | return err("缺少实体类型或记录名称。"); |
| 220 | } | 141 | } |
| 221 | - Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 222 | - if (form == null) { | ||
| 223 | - return err("找不到「" + entityKeyword + "」对应的可操作主表。"); | ||
| 224 | - } | ||
| 225 | - String formId = str(form.get("sFormId")); | ||
| 226 | - String moduleId = str(form.get("sModuleId")); | ||
| 227 | - String table = str(form.get("sDataSource")); | ||
| 228 | - if (!identity.canAccessModule(moduleId)) { | ||
| 229 | - return err("你没有操作「" + entityKeyword + "」的权限。"); | ||
| 230 | - } | ||
| 231 | - String nameField = queryOne( | ||
| 232 | - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 233 | - "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 234 | - JsonNode root; | ||
| 235 | - try { | ||
| 236 | - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); | ||
| 237 | - } catch (Exception e) { | ||
| 238 | - return err("定位记录失败:" + e.getMessage()); | ||
| 239 | - } | ||
| 240 | - if (root.path("code").asInt(0) < 0) { | ||
| 241 | - return err("定位记录失败:" + root.path("msg").asText("未知错误")); | ||
| 242 | - } | ||
| 243 | - JsonNode rows = root.path("dataset").path("rows"); | ||
| 244 | - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 245 | - int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 246 | - if (n == 0) { | ||
| 247 | - return err("没有找到名称含「" + recordKeyword + "」的记录。"); | 142 | + Located l = locateRecord(entityKeyword, recordKeyword, "删除"); |
| 143 | + if (l.error != null) { | ||
| 144 | + return l.error; | ||
| 248 | } | 145 | } |
| 249 | - if (n > 1) { | ||
| 250 | - StringBuilder names = new StringBuilder(); | ||
| 251 | - for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 252 | - if (i > 0) names.append("、"); | ||
| 253 | - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 254 | - } | ||
| 255 | - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只删其中一条。"); | ||
| 256 | - } | ||
| 257 | - JsonNode rec = data.get(0); | ||
| 258 | - String billId = rec.path("sId").asText(null); | ||
| 259 | - if (isBlank(billId)) { | ||
| 260 | - return err("定位到的记录缺少主键 sId,无法安全删除。"); | ||
| 261 | - } | ||
| 262 | - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | ||
| 263 | - String description = "删除【" + recordName + "】(" + entityKeyword + ")"; | ||
| 264 | - String opId = ops.createDraft(identity.userId(), "delete", formId, moduleId, table, billId, | ||
| 265 | - null, null, recordName, null, description); | 146 | + String description = "删除【" + l.recordName + "】(" + entityKeyword + ")"; |
| 147 | + String opId = ops.createDraft(identity.userId(), "delete", l.formId, l.moduleId, l.table, l.billId, | ||
| 148 | + null, null, l.recordName, null, description); | ||
| 266 | 149 | ||
| 267 | Map<String, Object> out = new LinkedHashMap<>(); | 150 | Map<String, Object> out = new LinkedHashMap<>(); |
| 268 | out.put("opId", opId); | 151 | out.put("opId", opId); |
| @@ -386,52 +269,14 @@ public class ProposeWriteTool { | @@ -386,52 +269,14 @@ public class ProposeWriteTool { | ||
| 386 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { | 269 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { |
| 387 | return err("缺少单据类型或单号/名称。"); | 270 | return err("缺少单据类型或单号/名称。"); |
| 388 | } | 271 | } |
| 389 | - Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 390 | - if (form == null) { | ||
| 391 | - return err("找不到「" + entityKeyword + "」对应的可审核单据主表。"); | ||
| 392 | - } | ||
| 393 | - String formId = str(form.get("sFormId")); | ||
| 394 | - String moduleId = str(form.get("sModuleId")); | ||
| 395 | - String table = str(form.get("sDataSource")); | ||
| 396 | - if (!identity.canAccessModule(moduleId)) { | ||
| 397 | - return err("你没有审核「" + entityKeyword + "」的权限。"); | ||
| 398 | - } | ||
| 399 | - String nameField = queryOne( | ||
| 400 | - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 401 | - "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 402 | - JsonNode root; | ||
| 403 | - try { | ||
| 404 | - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); | ||
| 405 | - } catch (Exception e) { | ||
| 406 | - return err("定位单据失败:" + e.getMessage()); | ||
| 407 | - } | ||
| 408 | - if (root.path("code").asInt(0) < 0) { | ||
| 409 | - return err("定位单据失败:" + root.path("msg").asText("未知错误")); | ||
| 410 | - } | ||
| 411 | - JsonNode rows = root.path("dataset").path("rows"); | ||
| 412 | - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 413 | - int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 414 | - if (n == 0) { | ||
| 415 | - return err("没有找到含「" + recordKeyword + "」的单据。"); | ||
| 416 | - } | ||
| 417 | - if (n > 1) { | ||
| 418 | - StringBuilder names = new StringBuilder(); | ||
| 419 | - for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 420 | - if (i > 0) names.append("、"); | ||
| 421 | - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 422 | - } | ||
| 423 | - return err("匹配到多条单据(" + names + "),请提供更精确的单号,只审核其中一条。"); | ||
| 424 | - } | ||
| 425 | - JsonNode rec = data.get(0); | ||
| 426 | - String billId = rec.path("sId").asText(null); | ||
| 427 | - if (isBlank(billId)) { | ||
| 428 | - return err("定位到的单据缺少主键 sId,无法审核。"); | ||
| 429 | - } | ||
| 430 | - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | ||
| 431 | String verb = iFlag == 1 ? "审核" : "反审核"; | 272 | String verb = iFlag == 1 ? "审核" : "反审核"; |
| 432 | - String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; | 273 | + Located l = locateRecord(entityKeyword, recordKeyword, verb); |
| 274 | + if (l.error != null) { | ||
| 275 | + return l.error; | ||
| 276 | + } | ||
| 277 | + String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; | ||
| 433 | // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) | 278 | // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) |
| 434 | - String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, | 279 | + String opId = ops.createDraft(identity.userId(), "examine", l.formId, l.moduleId, l.table, l.billId, |
| 435 | null, null, null, String.valueOf(iFlag), description); | 280 | null, null, null, String.valueOf(iFlag), description); |
| 436 | 281 | ||
| 437 | Map<String, Object> out = new LinkedHashMap<>(); | 282 | Map<String, Object> out = new LinkedHashMap<>(); |
| @@ -630,6 +475,70 @@ public class ProposeWriteTool { | @@ -630,6 +475,70 @@ public class ProposeWriteTool { | ||
| 630 | return resolver.resolveMasterForm(entityKeyword); | 475 | return resolver.resolveMasterForm(entityKeyword); |
| 631 | } | 476 | } |
| 632 | 477 | ||
| 478 | + /** {@link #locateRecord} 的结果:error 非空表示失败(已是 err() JSON);否则携带定位到的主表与唯一记录。 */ | ||
| 479 | + private static final class Located { | ||
| 480 | + String error; | ||
| 481 | + String formId; | ||
| 482 | + String moduleId; | ||
| 483 | + String table; | ||
| 484 | + JsonNode rec; | ||
| 485 | + String billId; | ||
| 486 | + String recordName; | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称字段模糊匹配 → 0 条/多条/缺主键报错。 */ | ||
| 490 | + private Located locateRecord(String entityKeyword, String recordKeyword, String verb) { | ||
| 491 | + Located l = new Located(); | ||
| 492 | + Map<String, Object> form = resolveForm(entityKeyword.trim()); | ||
| 493 | + if (form == null) { | ||
| 494 | + l.error = err("找不到「" + entityKeyword + "」对应的可操作主表。"); | ||
| 495 | + return l; | ||
| 496 | + } | ||
| 497 | + l.formId = str(form.get("sFormId")); | ||
| 498 | + l.moduleId = str(form.get("sModuleId")); | ||
| 499 | + l.table = str(form.get("sDataSource")); | ||
| 500 | + if (!identity.canAccessModule(l.moduleId)) { | ||
| 501 | + l.error = err("你没有" + verb + "「" + entityKeyword + "」的权限。"); | ||
| 502 | + return l; | ||
| 503 | + } | ||
| 504 | + String nameField = resolver.resolveNameField(l.table); | ||
| 505 | + JsonNode root; | ||
| 506 | + try { | ||
| 507 | + root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim()); | ||
| 508 | + } catch (Exception e) { | ||
| 509 | + l.error = err("定位记录失败:" + e.getMessage()); | ||
| 510 | + return l; | ||
| 511 | + } | ||
| 512 | + if (root.path("code").asInt(0) < 0) { | ||
| 513 | + l.error = err("定位记录失败:" + root.path("msg").asText("未知错误")); | ||
| 514 | + return l; | ||
| 515 | + } | ||
| 516 | + JsonNode rows = root.path("dataset").path("rows"); | ||
| 517 | + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 518 | + int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 519 | + if (n == 0) { | ||
| 520 | + l.error = err("没有找到名称含「" + recordKeyword + "」的记录。"); | ||
| 521 | + return l; | ||
| 522 | + } | ||
| 523 | + if (n > 1) { | ||
| 524 | + StringBuilder names = new StringBuilder(); | ||
| 525 | + for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 526 | + if (i > 0) names.append("、"); | ||
| 527 | + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 528 | + } | ||
| 529 | + l.error = err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); | ||
| 530 | + return l; | ||
| 531 | + } | ||
| 532 | + l.rec = data.get(0); | ||
| 533 | + l.billId = l.rec.path("sId").asText(null); | ||
| 534 | + if (isBlank(l.billId)) { | ||
| 535 | + l.error = err("定位到的记录缺少主键 sId,无法" + verb + "。"); | ||
| 536 | + return l; | ||
| 537 | + } | ||
| 538 | + l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword); | ||
| 539 | + return l; | ||
| 540 | + } | ||
| 541 | + | ||
| 633 | private static String str(Object o) { | 542 | private static String str(Object o) { |
| 634 | return o == null ? null : o.toString(); | 543 | return o == null ? null : o.toString(); |
| 635 | } | 544 | } |
src/main/java/com/xly/tool/QueryTool.java deleted
| 1 | -package com.xly.tool; | ||
| 2 | - | ||
| 3 | -import com.xly.agent.AgentIdentity; | ||
| 4 | -import com.xly.service.AuditService; | ||
| 5 | -import com.xly.service.FormResolverService; | ||
| 6 | -import dev.langchain4j.agent.tool.P; | ||
| 7 | -import dev.langchain4j.agent.tool.Tool; | ||
| 8 | -import dev.langchain4j.model.chat.ChatModel; | ||
| 9 | -import net.sf.jsqlparser.expression.StringValue; | ||
| 10 | -import net.sf.jsqlparser.expression.operators.conditional.AndExpression; | ||
| 11 | -import net.sf.jsqlparser.expression.operators.relational.EqualsTo; | ||
| 12 | -import net.sf.jsqlparser.parser.CCJSqlParserUtil; | ||
| 13 | -import net.sf.jsqlparser.schema.Column; | ||
| 14 | -import net.sf.jsqlparser.schema.Table; | ||
| 15 | -import net.sf.jsqlparser.statement.Statement; | ||
| 16 | -import net.sf.jsqlparser.statement.select.PlainSelect; | ||
| 17 | -import net.sf.jsqlparser.statement.select.Select; | ||
| 18 | -import net.sf.jsqlparser.util.TablesNamesFinder; | ||
| 19 | -import org.springframework.jdbc.core.JdbcTemplate; | ||
| 20 | - | ||
| 21 | -import java.util.HashSet; | ||
| 22 | -import java.util.LinkedHashMap; | ||
| 23 | -import java.util.List; | ||
| 24 | -import java.util.Map; | ||
| 25 | -import java.util.Set; | ||
| 26 | -import java.util.concurrent.ConcurrentHashMap; | ||
| 27 | -import java.util.regex.Pattern; | ||
| 28 | - | ||
| 29 | -/** | ||
| 30 | - * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 | ||
| 31 | - * (跨表汇总、按条件计数排名等)。 | ||
| 32 | - * | ||
| 33 | - * <p>安全栈(架构 §9):用 coder 模型据 KG 字段字典接地生成 SQL → jsqlparser 强制**单条 SELECT** → | ||
| 34 | - * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → | ||
| 35 | - * **表白名单**({@link #allowedTables()}:viw_* + 表单数据源 + 字段字典表,挡掉 gdslogininfo / | ||
| 36 | - * sysjurisdiction / ai_op_queue 等敏感表) → **AST 注入租户谓词**({@link #applyTenant},单表查询按 | ||
| 37 | - * {@code sBrandsId}) → 强制 LIMIT。SQL 全部入审计。 | ||
| 38 | - * | ||
| 39 | - * <p>尚未落地的加固项:多表 JOIN 只靠 prompt 提示带租户过滤(AST 注入只覆盖单表)、 | ||
| 40 | - * 以及独立的只读 MySQL 账号(当前与应用共用连接池)。 | ||
| 41 | - */ | ||
| 42 | -public class QueryTool { | ||
| 43 | - | ||
| 44 | - private final ChatModel sqlModel; | ||
| 45 | - private final JdbcTemplate jdbc; | ||
| 46 | - private final AuditService audit; | ||
| 47 | - private final AgentIdentity identity; | ||
| 48 | - private final FormResolverService resolver; | ||
| 49 | - | ||
| 50 | - public QueryTool(ChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, | ||
| 51 | - AgentIdentity identity, FormResolverService resolver) { | ||
| 52 | - this.sqlModel = sqlModel; | ||
| 53 | - this.jdbc = jdbc; | ||
| 54 | - this.audit = audit; | ||
| 55 | - this.identity = identity; | ||
| 56 | - this.resolver = resolver; | ||
| 57 | - } | ||
| 58 | - | ||
| 59 | - @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" | ||
| 60 | - + "(如跨表汇总、按条件计数、排名、分组统计)。仅在 readFormData / lookupRecord 无法回答时才用。") | ||
| 61 | - public String queryData(@P("用自然语言描述要统计/分析什么") String question) { | ||
| 62 | - if (question == null || question.isBlank()) { | ||
| 63 | - return "请描述要查询统计的内容。"; | ||
| 64 | - } | ||
| 65 | - String hint = schemaHint(question); | ||
| 66 | - String sql = null; | ||
| 67 | - String lastErr = null; | ||
| 68 | - // 自修复重试:SQL 校验/执行报错就把错误喂回模型重新生成,最多 3 次 | ||
| 69 | - for (int attempt = 0; attempt < 3; attempt++) { | ||
| 70 | - try { | ||
| 71 | - sql = cleanSql(sqlModel.chat(buildPrompt(hint, question, sql, lastErr))); | ||
| 72 | - } catch (Exception e) { | ||
| 73 | - return "生成查询失败:" + e.getMessage(); | ||
| 74 | - } | ||
| 75 | - String reject = validate(sql); | ||
| 76 | - if (reject != null) { | ||
| 77 | - lastErr = reject; | ||
| 78 | - if (attempt < 2) { | ||
| 79 | - continue; | ||
| 80 | - } | ||
| 81 | - audit.log(null, null, "query", "REJECTED", sql, false, reject); | ||
| 82 | - return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | ||
| 83 | - } | ||
| 84 | - String limited = forceLimit(applyTenant(sql)); | ||
| 85 | - try { | ||
| 86 | - List<Map<String, Object>> rows = jdbc.queryForList(limited); | ||
| 87 | - audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); | ||
| 88 | - return formatRows(rows); | ||
| 89 | - } catch (Exception e) { | ||
| 90 | - lastErr = rootMsg(e); | ||
| 91 | - if (attempt < 2) { | ||
| 92 | - continue; // 下一轮把错误喂回模型自修复 | ||
| 93 | - } | ||
| 94 | - audit.log(null, null, "query", "fail", limited, false, lastErr); | ||
| 95 | - return "查询执行失败(已尝试自修复):" + lastErr; | ||
| 96 | - } | ||
| 97 | - } | ||
| 98 | - return "查询失败。"; | ||
| 99 | - } | ||
| 100 | - | ||
| 101 | - private String buildPrompt(String hint, String question, String prevSql, String prevErr) { | ||
| 102 | - String repair = (prevSql == null || prevErr == null) ? "" : | ||
| 103 | - "\n\n上一条 SQL:\n" + prevSql + "\n执行/校验报错:" + prevErr + | ||
| 104 | - "\n请**修正该错误**后重新生成一条正确的 SELECT(注意用对表名列名、别用中文别名)。"; | ||
| 105 | - return """ | ||
| 106 | - 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 | ||
| 107 | - 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): | ||
| 108 | - %s | ||
| 109 | - 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表; | ||
| 110 | - 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据); | ||
| 111 | - **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; | ||
| 112 | - **给用户看的是名称不是内部ID**:若按某实体(产品/客户/物料/供应商)分组或排名,必须 JOIN 该实体主表、 | ||
| 113 | - 在结果里返回它的**名称列**(如 eleproduct.sProductName、elecustomer.sCustomerName),不要只返回 *Id 列; | ||
| 114 | - 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 | ||
| 115 | - 问题:%s%s | ||
| 116 | - """.formatted(hint, brandHint(), question, repair); | ||
| 117 | - } | ||
| 118 | - | ||
| 119 | - private String brandHint() { | ||
| 120 | - String b = identity == null ? null : identity.brandsId(); | ||
| 121 | - return (b == null || b.isBlank()) ? "本企业" : b; | ||
| 122 | - } | ||
| 123 | - | ||
| 124 | - private String rootMsg(Throwable e) { | ||
| 125 | - Throwable r = e; | ||
| 126 | - while (r.getCause() != null && r.getCause() != r) { | ||
| 127 | - r = r.getCause(); | ||
| 128 | - } | ||
| 129 | - String m = r.getMessage(); | ||
| 130 | - return m == null ? e.toString() : (m.length() > 300 ? m.substring(0, 300) : m); | ||
| 131 | - } | ||
| 132 | - | ||
| 133 | - /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ | ||
| 134 | - private String schemaHint(String question) { | ||
| 135 | - StringBuilder sb = new StringBuilder(); | ||
| 136 | - try { | ||
| 137 | - List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 138 | - "SELECT fd.sTable, " + | ||
| 139 | - "GROUP_CONCAT(DISTINCT CONCAT(fd.sField,'=',fd.sChinese) ORDER BY fd.iFormUses DESC SEPARATOR ', ') cols, " + | ||
| 140 | - "SUM(fd.iFormUses) usage_ " + | ||
| 141 | - "FROM viw_kg_field_dict fd " + | ||
| 142 | - "WHERE CHAR_LENGTH(fd.sChinese)>=2 AND INSTR(?, fd.sChinese)>0 " + | ||
| 143 | - "AND fd.sTable NOT LIKE 'viw%' " + | ||
| 144 | - "AND fd.sTable IN (SELECT DISTINCT sDataSource FROM viw_ai_useful_forms) " + | ||
| 145 | - "GROUP BY fd.sTable ORDER BY usage_ DESC, COUNT(*) DESC LIMIT 6", question); | ||
| 146 | - for (Map<String, Object> r : rows) { | ||
| 147 | - String cols = String.valueOf(r.get("cols")); | ||
| 148 | - if (cols.length() > 400) { | ||
| 149 | - cols = cols.substring(0, 400) + "…"; | ||
| 150 | - } | ||
| 151 | - sb.append("- ").append(r.get("sTable")).append("(").append(cols).append(")\n"); | ||
| 152 | - } | ||
| 153 | - } catch (Exception ignore) { | ||
| 154 | - } | ||
| 155 | - if (sb.length() == 0) { | ||
| 156 | - sb.append("(未匹配到具体表;请在问题里使用业务术语,如 客户 / 订单 / 金额 / 数量)\n"); | ||
| 157 | - } | ||
| 158 | - return sb.toString(); | ||
| 159 | - } | ||
| 160 | - | ||
| 161 | - private String cleanSql(String raw) { | ||
| 162 | - if (raw == null) { | ||
| 163 | - return ""; | ||
| 164 | - } | ||
| 165 | - String s = raw.replace("```sql", "").replace("```", "").trim(); | ||
| 166 | - int i = s.toLowerCase().indexOf("select"); | ||
| 167 | - if (i > 0) { | ||
| 168 | - s = s.substring(i); | ||
| 169 | - } | ||
| 170 | - int semi = s.indexOf(';'); | ||
| 171 | - if (semi >= 0) { | ||
| 172 | - s = s.substring(0, semi); | ||
| 173 | - } | ||
| 174 | - return s.trim(); | ||
| 175 | - } | ||
| 176 | - | ||
| 177 | - // AI 可用业务表/视图白名单(静态元数据,跨请求缓存);含 sBrandsId 列的表缓存。 | ||
| 178 | - private static volatile Set<String> ALLOWED_TABLES; | ||
| 179 | - private static final ConcurrentHashMap<String, Boolean> BRAND_COL = new ConcurrentHashMap<>(); | ||
| 180 | - | ||
| 181 | - /** | ||
| 182 | - * 单条 SELECT + 挡危险构造 + **表白名单**(架构 §9 的“视图白名单”)。 | ||
| 183 | - * 白名单 = AI 可用的业务表/视图(viw_* + 表单数据源 + 字段字典表),把凭证/权限/暂存等敏感表挡在外面 | ||
| 184 | - * (如 gdslogininfo、sysjurisdiction、ai_op_queue),防 NL2SQL 击穿权限。返回 null=通过,否则=拒绝原因。 | ||
| 185 | - */ | ||
| 186 | - private String validate(String sql) { | ||
| 187 | - if (sql == null || sql.isBlank()) { | ||
| 188 | - return "未生成SQL"; | ||
| 189 | - } | ||
| 190 | - String low = sql.toLowerCase(); | ||
| 191 | - String[] bad = {"into outfile", "into dumpfile", "load_file", "load data", | ||
| 192 | - "information_schema", "sleep(", "benchmark(", "sys.", "mysql."}; | ||
| 193 | - for (String b : bad) { | ||
| 194 | - if (low.contains(b)) { | ||
| 195 | - return "含禁止构造: " + b; | ||
| 196 | - } | ||
| 197 | - } | ||
| 198 | - Statement stmt; | ||
| 199 | - try { | ||
| 200 | - stmt = CCJSqlParserUtil.parse(sql); | ||
| 201 | - } catch (Exception e) { | ||
| 202 | - return "SQL 解析失败"; | ||
| 203 | - } | ||
| 204 | - if (!(stmt instanceof Select)) { | ||
| 205 | - return "只允许 SELECT"; | ||
| 206 | - } | ||
| 207 | - Set<String> allowed = allowedTables(); | ||
| 208 | - if (!allowed.isEmpty()) { | ||
| 209 | - List<String> tables; | ||
| 210 | - try { | ||
| 211 | - tables = new TablesNamesFinder().getTableList(stmt); | ||
| 212 | - } catch (Exception e) { | ||
| 213 | - return "无法解析查询涉及的表"; | ||
| 214 | - } | ||
| 215 | - for (String t : tables) { | ||
| 216 | - if (!allowed.contains(normTable(t))) { | ||
| 217 | - return "涉及不允许访问的表(" + normTable(t) + ")"; | ||
| 218 | - } | ||
| 219 | - } | ||
| 220 | - } | ||
| 221 | - return null; | ||
| 222 | - } | ||
| 223 | - | ||
| 224 | - /** AI 可用表/视图集合(小写):所有 viw_* 视图 + 表单数据源 + 字段字典里出现的基础表。静态缓存。 */ | ||
| 225 | - private Set<String> allowedTables() { | ||
| 226 | - Set<String> c = ALLOWED_TABLES; | ||
| 227 | - if (c != null) { | ||
| 228 | - return c; | ||
| 229 | - } | ||
| 230 | - Set<String> s = new HashSet<>(); | ||
| 231 | - try { | ||
| 232 | - for (Map<String, Object> r : jdbc.queryForList( | ||
| 233 | - "SELECT LOWER(TABLE_NAME) t FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'viw\\_%'")) { | ||
| 234 | - s.add(String.valueOf(r.get("t"))); | ||
| 235 | - } | ||
| 236 | - for (Map<String, Object> r : jdbc.queryForList( | ||
| 237 | - "SELECT DISTINCT LOWER(sDataSource) t FROM viw_ai_useful_forms WHERE IFNULL(sDataSource,'')<>''")) { | ||
| 238 | - s.add(String.valueOf(r.get("t"))); | ||
| 239 | - } | ||
| 240 | - for (Map<String, Object> r : jdbc.queryForList( | ||
| 241 | - "SELECT DISTINCT LOWER(sTable) t FROM viw_kg_field_dict WHERE IFNULL(sTable,'')<>''")) { | ||
| 242 | - s.add(String.valueOf(r.get("t"))); | ||
| 243 | - } | ||
| 244 | - } catch (Exception ignore) { | ||
| 245 | - // 元数据不可用时返回空集 → 不启用白名单(保持可用),但仍有 SELECT-only + 禁止构造兜底。 | ||
| 246 | - } | ||
| 247 | - ALLOWED_TABLES = s; | ||
| 248 | - return s; | ||
| 249 | - } | ||
| 250 | - | ||
| 251 | - /** 规整表名:去反引号、去 schema 前缀、转小写。 */ | ||
| 252 | - private static String normTable(String t) { | ||
| 253 | - if (t == null) { | ||
| 254 | - return ""; | ||
| 255 | - } | ||
| 256 | - String x = t.replace("`", "").trim(); | ||
| 257 | - int dot = x.lastIndexOf('.'); | ||
| 258 | - if (dot >= 0) { | ||
| 259 | - x = x.substring(dot + 1); | ||
| 260 | - } | ||
| 261 | - return x.toLowerCase(); | ||
| 262 | - } | ||
| 263 | - | ||
| 264 | - /** | ||
| 265 | - * 租户注入(架构 §9):单表(无 JOIN)且该表含 sBrandsId 列、且身份带品牌时,追加 | ||
| 266 | - * {@code AND sBrandsId='<brand>'},把结果限定在本企业。视图(viw_*)通常已按品牌预筛,跳过。 | ||
| 267 | - * 任何异常都退回原 SQL(不因注入失败而阻断,白名单已是主要边界)。 | ||
| 268 | - */ | ||
| 269 | - private String applyTenant(String sql) { | ||
| 270 | - String brand = identity == null ? null : identity.brandsId(); | ||
| 271 | - if (brand == null || brand.isBlank()) { | ||
| 272 | - return sql; | ||
| 273 | - } | ||
| 274 | - try { | ||
| 275 | - Statement stmt = CCJSqlParserUtil.parse(sql); | ||
| 276 | - if (!(stmt instanceof Select)) { | ||
| 277 | - return sql; | ||
| 278 | - } | ||
| 279 | - Select select = (Select) stmt; | ||
| 280 | - if (!(select.getSelectBody() instanceof PlainSelect)) { | ||
| 281 | - return sql; | ||
| 282 | - } | ||
| 283 | - PlainSelect ps = (PlainSelect) select.getSelectBody(); | ||
| 284 | - if (ps.getJoins() != null && !ps.getJoins().isEmpty()) { | ||
| 285 | - return sql; // 多表:交给白名单,不做注入 | ||
| 286 | - } | ||
| 287 | - if (!(ps.getFromItem() instanceof Table)) { | ||
| 288 | - return sql; | ||
| 289 | - } | ||
| 290 | - String table = normTable(((Table) ps.getFromItem()).getName()); | ||
| 291 | - if (table.startsWith("viw")) { | ||
| 292 | - return sql; // 视图预筛,不注入 | ||
| 293 | - } | ||
| 294 | - if (!hasBrandCol(table)) { | ||
| 295 | - return sql; | ||
| 296 | - } | ||
| 297 | - EqualsTo eq = new EqualsTo(); | ||
| 298 | - eq.setLeftExpression(new Column("sBrandsId")); | ||
| 299 | - eq.setRightExpression(new StringValue(brand)); | ||
| 300 | - ps.setWhere(ps.getWhere() == null ? eq : new AndExpression(ps.getWhere(), eq)); | ||
| 301 | - return select.toString(); | ||
| 302 | - } catch (Exception e) { | ||
| 303 | - return sql; | ||
| 304 | - } | ||
| 305 | - } | ||
| 306 | - | ||
| 307 | - /** 该基础表是否有 sBrandsId 列(缓存)。 */ | ||
| 308 | - private boolean hasBrandCol(String table) { | ||
| 309 | - return BRAND_COL.computeIfAbsent(table, t -> { | ||
| 310 | - try { | ||
| 311 | - Integer n = jdbc.queryForObject( | ||
| 312 | - "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + | ||
| 313 | - "AND TABLE_NAME=? AND COLUMN_NAME='sBrandsId'", Integer.class, t); | ||
| 314 | - return n != null && n > 0; | ||
| 315 | - } catch (Exception e) { | ||
| 316 | - return false; | ||
| 317 | - } | ||
| 318 | - }); | ||
| 319 | - } | ||
| 320 | - | ||
| 321 | - private String forceLimit(String sql) { | ||
| 322 | - String low = sql.toLowerCase(); | ||
| 323 | - if (!low.matches("(?s).*\\blimit\\b.*")) { | ||
| 324 | - return sql.trim() + " LIMIT 100"; | ||
| 325 | - } | ||
| 326 | - return sql; | ||
| 327 | - } | ||
| 328 | - | ||
| 329 | - // 裸主键ID(业务 sId 通常为 15+ 位数字串)。仅对严格匹配的长数字尝试解析,普通数字(数量/金额)不受影响。 | ||
| 330 | - private static final Pattern LONG_ID = Pattern.compile("\\d{15,}"); | ||
| 331 | - private static volatile Map<String, String> NAME_TABLES; // 主表 -> 名称字段(静态缓存) | ||
| 332 | - private final Map<String, String> idNameCache = new ConcurrentHashMap<>(); // 本次请求内的 id->名称 缓存 | ||
| 333 | - | ||
| 334 | - /** 被外键引用的主表集合及其名称字段(客户/产品/物料/供应商…)。用于把裸ID回解析成名称。 */ | ||
| 335 | - private Map<String, String> nameTables() { | ||
| 336 | - Map<String, String> c = NAME_TABLES; | ||
| 337 | - if (c != null) { | ||
| 338 | - return c; | ||
| 339 | - } | ||
| 340 | - Map<String, String> m = new LinkedHashMap<>(); | ||
| 341 | - try { | ||
| 342 | - for (Map<String, Object> r : jdbc.queryForList( | ||
| 343 | - "SELECT DISTINCT sFkTable FROM viw_kg_field_dict WHERE IFNULL(sFkTable,'')<>'' " + | ||
| 344 | - "AND sFkTable NOT LIKE 'viw%'")) { | ||
| 345 | - String t = String.valueOf(r.get("sFkTable")); | ||
| 346 | - if (t == null || t.isBlank()) continue; | ||
| 347 | - String nf = resolver.resolveNameField(t); | ||
| 348 | - if (nf != null && !nf.isBlank()) { | ||
| 349 | - m.put(t, nf); | ||
| 350 | - } | ||
| 351 | - } | ||
| 352 | - } catch (Exception ignore) { | ||
| 353 | - } | ||
| 354 | - NAME_TABLES = m; | ||
| 355 | - return m; | ||
| 356 | - } | ||
| 357 | - | ||
| 358 | - /** 长ID → 名称(在被引用主表里按 sId 命中即返回;找不到返回 null,保留原ID)。 */ | ||
| 359 | - private String resolveId(String val) { | ||
| 360 | - String cached = idNameCache.get(val); | ||
| 361 | - if (cached != null) { | ||
| 362 | - return cached.isEmpty() ? null : cached; | ||
| 363 | - } | ||
| 364 | - for (Map.Entry<String, String> e : nameTables().entrySet()) { | ||
| 365 | - try { | ||
| 366 | - List<Map<String, Object>> r = jdbc.queryForList( | ||
| 367 | - "SELECT `" + e.getValue() + "` nm FROM `" + e.getKey() + "` WHERE sId=? LIMIT 1", val); | ||
| 368 | - if (!r.isEmpty()) { | ||
| 369 | - Object nm = r.get(0).get("nm"); | ||
| 370 | - if (nm != null && !String.valueOf(nm).isBlank()) { | ||
| 371 | - String s = String.valueOf(nm); | ||
| 372 | - idNameCache.put(val, s); | ||
| 373 | - return s; | ||
| 374 | - } | ||
| 375 | - } | ||
| 376 | - } catch (Exception ignore) { | ||
| 377 | - } | ||
| 378 | - } | ||
| 379 | - idNameCache.put(val, ""); | ||
| 380 | - return null; | ||
| 381 | - } | ||
| 382 | - | ||
| 383 | - private String formatRows(List<Map<String, Object>> rows) { | ||
| 384 | - if (rows.isEmpty()) { | ||
| 385 | - return "查询完成,没有匹配的数据。"; | ||
| 386 | - } | ||
| 387 | - List<String> cols = List.copyOf(rows.get(0).keySet()); | ||
| 388 | - StringBuilder sb = new StringBuilder(); | ||
| 389 | - sb.append("查询结果(").append(rows.size()).append(" 行):\n\n"); | ||
| 390 | - sb.append("| ").append(String.join(" | ", cols)).append(" |\n"); | ||
| 391 | - sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); | ||
| 392 | - int shown = 0; | ||
| 393 | - for (Map<String, Object> r : rows) { | ||
| 394 | - if (shown++ >= 30) { | ||
| 395 | - sb.append("| … 仅显示前 30 行 |").append(" |".repeat(Math.max(0, cols.size() - 1))).append("\n"); | ||
| 396 | - break; | ||
| 397 | - } | ||
| 398 | - StringBuilder line = new StringBuilder("| "); | ||
| 399 | - for (String c : cols) { | ||
| 400 | - Object v = r.get(c); | ||
| 401 | - String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); | ||
| 402 | - // 把裸的长ID(如产品/客户 sId)就地解析成名称,给用户看名字不是ID | ||
| 403 | - if (LONG_ID.matcher(s).matches()) { | ||
| 404 | - String nm = resolveId(s); | ||
| 405 | - if (nm != null) s = nm; | ||
| 406 | - } | ||
| 407 | - if (s.length() > 30) { | ||
| 408 | - s = s.substring(0, 30) + "…"; | ||
| 409 | - } | ||
| 410 | - line.append(s).append(" | "); | ||
| 411 | - } | ||
| 412 | - sb.append(line).append("\n"); | ||
| 413 | - } | ||
| 414 | - return sb.toString(); | ||
| 415 | - } | ||
| 416 | -} |
src/main/java/com/xly/tool/SkillTool.java deleted
| 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
| @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; | @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 5 | import com.xly.agent.AgentIdentity; | 5 | import com.xly.agent.AgentIdentity; |
| 6 | import com.xly.agent.Intent; | 6 | import com.xly.agent.Intent; |
| 7 | import com.xly.agent.ReActAgent; | 7 | import com.xly.agent.ReActAgent; |
| 8 | -import com.xly.agent.ToolScope; | ||
| 9 | import com.xly.config.AgentFactory; | 8 | import com.xly.config.AgentFactory; |
| 10 | import com.xly.service.AuthzService; | 9 | import com.xly.service.AuthzService; |
| 11 | import com.xly.service.ConversationService; | 10 | import com.xly.service.ConversationService; |
| @@ -36,16 +35,15 @@ import java.util.concurrent.Executors; | @@ -36,16 +35,15 @@ import java.util.concurrent.Executors; | ||
| 36 | /** | 35 | /** |
| 37 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 | 36 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 |
| 38 | * | 37 | * |
| 39 | - * <p><b>新编排(架构 §5 意图门 + 确定性路由)</b>:不再让一个弱模型在全部 9 个工具里一次性盲选。 | ||
| 40 | - * 每轮先用 {@link IntentService} 做**受约束 JSON** 的意图+实体分类,再确定性路由: | 38 | + * <p><b>编排(架构 §5 意图门 + 确定性路由)</b>:每轮先用 {@link IntentService} 做**受约束 JSON** |
| 39 | + * 的 4 类意图+实体分类,再确定性路由: | ||
| 41 | * <ul> | 40 | * <ul> |
| 42 | * <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单 | 41 | * <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单 |
| 43 | * (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);</li> | 42 | * (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);</li> |
| 44 | - * <li><b>查询</b> → 只暴露读工具的 READ-scope agent;</li> | ||
| 45 | - * <li><b>修改/删除/审核</b> → 只暴露写工具的 WRITE-scope agent(人在环);</li> | ||
| 46 | - * <li><b>不清楚/分类失败</b> → 兜底 FULL-scope,原文交给 agent。</li> | 43 | + * <li><b>操作已有单据</b> → 写槽位抽取 + 确定性 proposeWrite(人在环,具体动作从原话推出);</li> |
| 44 | + * <li><b>查询/其他/分类失败</b> → 统一的 6 工具 ReAct agent(查询附意图 grounding)。</li> | ||
| 47 | * </ul> | 45 | * </ul> |
| 48 | - * 收窄工具集 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 | 46 | + * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 |
| 49 | * | 47 | * |
| 50 | * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 | 48 | * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 |
| 51 | * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 | 49 | * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 |
| @@ -119,12 +117,12 @@ public class AgentChatController { | @@ -119,12 +117,12 @@ public class AgentChatController { | ||
| 119 | 117 | ||
| 120 | /** 意图门 + 确定性路由。 */ | 118 | /** 意图门 + 确定性路由。 */ |
| 121 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { | 119 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { |
| 122 | - // 0) 表单提交 → 直接走写(执行 proposeWrite(action=create)),不再重新分类。 | 120 | + // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 |
| 123 | if (userInput.contains(FORM_SUBMIT_MARK)) { | 121 | if (userInput.contains(FORM_SUBMIT_MARK)) { |
| 124 | - runAgent(emitter, convId, identity, ToolScope.WRITE, userInput); | 122 | + runAgent(emitter, convId, identity, userInput); |
| 125 | return; | 123 | return; |
| 126 | } | 124 | } |
| 127 | - // 1) 意图门(失败时返回 UNCLEAR,走兜底)。 | 125 | + // 1) 意图门(失败时返回 其他,走兜底)。 |
| 128 | Intent it = intentService.classify(userInput); | 126 | Intent it = intentService.classify(userInput); |
| 129 | log.info("intent(conv={}): {} / {} / entities={} / missing={}", | 127 | log.info("intent(conv={}): {} / {} / entities={} / missing={}", |
| 130 | convId, it.intent, it.danju, it.describeEntities(), it.missing); | 128 | convId, it.intent, it.danju, it.describeEntities(), it.missing); |
| @@ -134,24 +132,18 @@ public class AgentChatController { | @@ -134,24 +132,18 @@ public class AgentChatController { | ||
| 134 | if (handleCreate(emitter, convId, identity, userInput, it)) { | 132 | if (handleCreate(emitter, convId, identity, userInput, it)) { |
| 135 | return; | 133 | return; |
| 136 | } | 134 | } |
| 137 | - // 无法确定性建表单 → 交给写 agent 处理(可能需要它先问清单据类型)。 | ||
| 138 | - runAgent(emitter, convId, identity, ToolScope.WRITE, ground(userInput, it)); | 135 | + // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 |
| 136 | + runAgent(emitter, convId, identity, ground(userInput, it)); | ||
| 139 | return; | 137 | return; |
| 140 | - case Intent.UPDATE: | ||
| 141 | - case Intent.DELETE: | ||
| 142 | - case Intent.EXAMINE: | 138 | + case Intent.OPERATE: |
| 143 | handleWrite(emitter, convId, identity, userInput, it); | 139 | handleWrite(emitter, convId, identity, userInput, it); |
| 144 | return; | 140 | return; |
| 145 | case Intent.QUERY: | 141 | case Intent.QUERY: |
| 146 | - runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it)); | 142 | + runAgent(emitter, convId, identity, ground(userInput, it)); |
| 147 | return; | 143 | return; |
| 148 | - case Intent.CHAT: | ||
| 149 | - runAgent(emitter, convId, identity, ToolScope.READ, userInput); | ||
| 150 | - return; | ||
| 151 | - case Intent.UNCLEAR: | ||
| 152 | default: | 144 | default: |
| 153 | - // 分类不确定/失败:给全部工具、原文交给 agent,尽量不丢能力。 | ||
| 154 | - runAgent(emitter, convId, identity, ToolScope.FULL, userInput); | 145 | + // 其他/分类失败:原文交给 agent,尽量不丢能力。 |
| 146 | + runAgent(emitter, convId, identity, userInput); | ||
| 155 | } | 147 | } |
| 156 | } | 148 | } |
| 157 | 149 | ||
| @@ -199,11 +191,10 @@ public class AgentChatController { | @@ -199,11 +191,10 @@ public class AgentChatController { | ||
| 199 | return false; | 191 | return false; |
| 200 | } | 192 | } |
| 201 | 193 | ||
| 202 | - /** 运行某一范围的 ReAct agent,把流式回调转成 SSE。 */ | ||
| 203 | - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, | ||
| 204 | - ToolScope scope, String text) { | 194 | + /** 运行 ReAct agent,把流式回调转成 SSE。 */ |
| 195 | + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text) { | ||
| 205 | try { | 196 | try { |
| 206 | - ReActAgent agent = agentFactory.build(identity, scope); | 197 | + ReActAgent agent = agentFactory.build(identity); |
| 207 | TokenStream ts = agent.chat(convId, text); | 198 | TokenStream ts = agent.chat(convId, text); |
| 208 | ts.onPartialResponse(token -> send(emitter, "token", token)) | 199 | ts.onPartialResponse(token -> send(emitter, "token", token)) |
| 209 | .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) | 200 | .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) |
| @@ -246,8 +237,8 @@ public class AgentChatController { | @@ -246,8 +237,8 @@ public class AgentChatController { | ||
| 246 | } | 237 | } |
| 247 | 238 | ||
| 248 | /** | 239 | /** |
| 249 | - * 写操作(修改/删除/审核)处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), | ||
| 250 | - * 齐了就带干净 grounding 交给写 agent 直接 propose*(它会自定位主表/记录), | 240 | + * 「操作已有单据」处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), |
| 241 | + * 齐了就确定性调 proposeWrite(它会自定位主表/记录), | ||
| 251 | * 缺了就**确定性问一次**并停下——两头都不进失控循环。 | 242 | * 缺了就**确定性问一次**并停下——两头都不进失控循环。 |
| 252 | */ | 243 | */ |
| 253 | private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, | 244 | private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, |
| @@ -256,7 +247,7 @@ public class AgentChatController { | @@ -256,7 +247,7 @@ public class AgentChatController { | ||
| 256 | log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", | 247 | log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", |
| 257 | convId, w.entityType, w.record, w.field, w.newValue); | 248 | convId, w.entityType, w.record, w.field, w.newValue); |
| 258 | 249 | ||
| 259 | - String action = deriveWriteAction(it.intent, userInput); | 250 | + String action = deriveWriteAction(userInput); |
| 260 | String ent = pickEntity(w.entityType, it.danju); | 251 | String ent = pickEntity(w.entityType, it.danju); |
| 261 | 252 | ||
| 262 | // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) | 253 | // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) |
| @@ -312,16 +303,15 @@ public class AgentChatController { | @@ -312,16 +303,15 @@ public class AgentChatController { | ||
| 312 | return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); | 303 | return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); |
| 313 | } | 304 | } |
| 314 | 305 | ||
| 315 | - /** 由意图 + 用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删)。 */ | ||
| 316 | - private String deriveWriteAction(String intent, String text) { | 306 | + /** 从用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删;「删除」默认=作废,安全可复原)。 */ |
| 307 | + private String deriveWriteAction(String text) { | ||
| 317 | String t = text == null ? "" : text; | 308 | String t = text == null ? "" : text; |
| 318 | if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; | 309 | if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; |
| 319 | if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; | 310 | if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; |
| 320 | if (t.contains("作废")) return "invalid"; | 311 | if (t.contains("作废")) return "invalid"; |
| 321 | if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; | 312 | if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; |
| 322 | - if (Intent.UPDATE.equals(intent)) return "update"; | ||
| 323 | - if (Intent.EXAMINE.equals(intent)) return "examine"; | ||
| 324 | - if (Intent.DELETE.equals(intent)) return "invalid"; // 业务单据"删除"默认=作废(安全、可复原) | 313 | + if (t.contains("审核") || t.contains("审批")) return "examine"; |
| 314 | + if (t.contains("删除") || t.contains("删掉") || t.contains("删了")) return "invalid"; | ||
| 325 | return "update"; | 315 | return "update"; |
| 326 | } | 316 | } |
| 327 | 317 |
src/main/resources/application.yml
| @@ -66,8 +66,6 @@ llm: | @@ -66,8 +66,6 @@ llm: | ||
| 66 | base-url: http://112.82.245.194:41434/v1 | 66 | base-url: http://112.82.245.194:41434/v1 |
| 67 | api-key: ollama # Ollama 不校验(任意非空);云端填真实 key | 67 | api-key: ollama # Ollama 不校验(任意非空);云端填真实 key |
| 68 | chat-model: qwen3.6-27b-iq3:latest | 68 | chat-model: qwen3.6-27b-iq3:latest |
| 69 | - # SQL/代码模型(QueryTool NL2SQL 专用) | ||
| 70 | - sql-model: qwen3.6-27b-iq3:latest | ||
| 71 | 69 | ||
| 72 | erp: | 70 | erp: |
| 73 | baseurl: http://118.178.19.35:8080/xlyEntry_saas | 71 | baseurl: http://118.178.19.35:8080/xlyEntry_saas |