Commit 0a4a32365ecc946d16d570feb88da8922bab605e

Authored by zichun
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
@@ -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