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 101 <artifactId>langchain4j-open-ai</artifactId>
102 102 <version>${langchain4j.version}</version>
103 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 104 </dependencies>
112 105  
113 106 <build>
... ...
src/main/java/com/xly/agent/Intent.java
... ... @@ -7,19 +7,17 @@ import java.util.List;
7 7 * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。
8 8 *
9 9 * <p>由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成
10   - * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**,
11   - * 而不是让弱模型在全部 9 个工具里盲选——这是治理「理解错/选错工具」的核心。
  10 + * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**:
  11 + * 新增→确定性表单、操作已有单据→写槽位抽取+确定性 proposeWrite、查询/其他→ReAct agent。
  12 + * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。
12 13 */
13 14 public class Intent {
14 15  
15   - /** 操作意图。 */
  16 + /** 操作意图(4 类)。 */
16 17 public static final String QUERY = "查询";
17 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 22 public static class Entity {
25 23 public String value;
... ... @@ -36,7 +34,7 @@ public class Intent {
36 34 public String newValue = ""; // 修改后的新值(删除/审核留空)
37 35 }
38 36  
39   - public String intent = UNCLEAR;
  37 + public String intent = OTHER;
40 38 public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单
41 39 public List<Entity> entities = new ArrayList<>();
42 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 11 * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。
12 12 *
13 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 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 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 4 import com.xly.agent.AgentIdentity;
5 5 import com.xly.agent.ReActAgent;
6   -import com.xly.agent.ToolScope;
7   -import com.xly.service.AuditService;
8 6 import com.xly.service.ErpClient;
9 7 import com.xly.service.FormResolverService;
10 8 import com.xly.service.OpService;
11   -import com.xly.service.SkillService;
12 9 import com.xly.service.SystemPromptService;
13 10 import com.xly.tool.ErpReadTool;
14 11 import com.xly.tool.FormCollectTool;
15 12 import com.xly.tool.InteractionTool;
16 13 import com.xly.tool.KgQueryTool;
17 14 import com.xly.tool.ProposeWriteTool;
18   -import com.xly.tool.QueryTool;
19   -import com.xly.tool.SkillTool;
20 15 import dev.langchain4j.memory.chat.MessageWindowChatMemory;
21   -import dev.langchain4j.model.chat.ChatModel;
22 16 import dev.langchain4j.model.chat.StreamingChatModel;
23 17 import dev.langchain4j.service.AiServices;
24 18 import org.springframework.beans.factory.annotation.Qualifier;
... ... @@ -30,16 +24,16 @@ import org.springframework.stereotype.Component;
30 24 *
31 25 * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
32 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 33 @Component
39 34 public class AgentFactory {
40 35  
41 36 private final StreamingChatModel streamingModel;
42   - private final ChatModel sqlModel;
43 37 private final RedisChatMemoryStore memoryStore;
44 38 private final SystemPromptService systemPromptService;
45 39  
... ... @@ -48,22 +42,16 @@ public class AgentFactory {
48 42 private final FormResolverService resolver;
49 43 private final OpService ops;
50 44 private final ObjectMapper mapper;
51   - private final AuditService audit;
52   - private final SkillService skillService;
53 45  
54 46 private final KgQueryTool kgQueryTool;
55   - private final SkillTool skillTool;
56 47 private final InteractionTool interactionTool;
57 48  
58 49 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
59   - @Qualifier("sqlChatModel") ChatModel sqlModel,
60 50 RedisChatMemoryStore memoryStore,
61 51 SystemPromptService systemPromptService,
62 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 54 this.streamingModel = streamingModel;
66   - this.sqlModel = sqlModel;
67 55 this.memoryStore = memoryStore;
68 56 this.systemPromptService = systemPromptService;
69 57 this.erp = erp;
... ... @@ -71,45 +59,19 @@ public class AgentFactory {
71 59 this.resolver = resolver;
72 60 this.ops = ops;
73 61 this.mapper = mapper;
74   - this.audit = audit;
75   - this.skillService = skillService;
76 62 this.kgQueryTool = kgQueryTool;
77   - this.skillTool = skillTool;
78 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 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 76 return AiServices.builder(ReActAgent.class)
115 77 .streamingChatModel(streamingModel)
... ... @@ -120,7 +82,7 @@ public class AgentFactory {
120 82 .maxMessages(30)
121 83 .chatMemoryStore(memoryStore)
122 84 .build())
123   - .systemMessageProvider(memoryId -> systemPrompt)
  85 + .systemMessageProvider(memoryId -> systemPromptService.prompt())
124 86 .build();
125 87 }
126 88  
... ...
src/main/java/com/xly/config/ModelConfig.java
... ... @@ -3,44 +3,13 @@ package com.xly.config;
3 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 4 import com.fasterxml.jackson.databind.SerializationFeature;
5 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 6 import org.springframework.context.annotation.Bean;
10 7 import org.springframework.context.annotation.Configuration;
11 8 import org.springframework.context.annotation.Primary;
12 9  
13   -import java.time.Duration;
14   -import java.util.List;
15   -
16 10 @Configuration
17 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 13 @Bean
45 14 @Primary
46 15 public ObjectMapper objectMapper() {
... ...
src/main/java/com/xly/service/FormResolverService.java
... ... @@ -83,6 +83,15 @@ public class FormResolverService {
83 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 95 // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。
87 96 private static final Set<String> SYS_EXACT = Set.of(
88 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 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 23 @Service
24 24 public class IntentService {
... ... @@ -26,18 +26,17 @@ public class IntentService {
26 26 private static final Logger log = LoggerFactory.getLogger(IntentService.class);
27 27  
28 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 41 private final LlmJsonClient llm;
43 42  
... ... @@ -45,7 +44,7 @@ public class IntentService {
45 44 this.llm = llm;
46 45 }
47 46  
48   - /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */
  47 + /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */
49 48 public Intent classify(String utterance) {
50 49 Intent out = new Intent();
51 50 if (utterance == null || utterance.isBlank()) {
... ... @@ -53,10 +52,10 @@ public class IntentService {
53 52 }
54 53 JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema());
55 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 56 return out;
58 57 }
59   - String intent = n.path("intent").asText(Intent.UNCLEAR);
  58 + String intent = n.path("intent").asText(Intent.OTHER);
60 59 out.intent = normalizeIntent(intent);
61 60 out.danju = n.path("danju").asText("");
62 61 JsonNode es = n.path("entities");
... ... @@ -117,19 +116,16 @@ public class IntentService {
117 116 }
118 117  
119 118 private static String normalizeIntent(String s) {
120   - if (s == null) return Intent.UNCLEAR;
  119 + if (s == null) return Intent.OTHER;
121 120 s = s.trim();
122 121 switch (s) {
123 122 case Intent.QUERY:
124 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 126 return s;
131 127 default:
132   - return Intent.UNCLEAR;
  128 + return Intent.OTHER;
133 129 }
134 130 }
135 131  
... ... @@ -145,7 +141,7 @@ public class IntentService {
145 141  
146 142 Map<String, Object> props = new LinkedHashMap<>();
147 143 props.put("intent", Map.of("type", "string",
148   - "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚")));
  144 + "enum", List.of("查询", "新增", "操作已有单据", "其他")));
149 145 props.put("danju", Map.of("type", "string"));
150 146 props.put("entities", Map.of("type", "array", "items", entityItem));
151 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 1 package com.xly.service;
2 2  
3   -import com.xly.agent.ToolScope;
4 3 import org.springframework.jdbc.core.JdbcTemplate;
5 4 import org.springframework.stereotype.Service;
6 5  
... ... @@ -8,25 +7,22 @@ import java.util.List;
8 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 17 @Service
18 18 public class SystemPromptService {
19 19  
20 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 25 this.jdbc = jdbc;
29   - this.skills = skills;
30 26 }
31 27  
32 28 private static final String HEADER =
... ... @@ -34,93 +30,38 @@ public class SystemPromptService {
34 30 + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"
35 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 43 return HEADER + """
53 44  
54   - 本轮任务是**查询 / 读取**数据(不做任何写操作)。
55   -
56 45 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
57 46 %s
58   - 【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做:
59   - %s
  47 + 【业务常识】
  48 + - 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。
  49 + - 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。
  50 +
60 51 【可用工具(只有这些)】
61 52 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
62   - - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。
63 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 67 private String renderDomainMap() {
... ...
src/main/java/com/xly/tool/ErpReadTool.java
... ... @@ -6,10 +6,8 @@ import com.xly.service.ErpClient;
6 6 import com.xly.service.FormResolverService;
7 7 import dev.langchain4j.agent.tool.P;
8 8 import dev.langchain4j.agent.tool.Tool;
9   -import org.springframework.jdbc.core.JdbcTemplate;
10 9  
11 10 import java.util.ArrayList;
12   -import java.util.HashMap;
13 11 import java.util.Iterator;
14 12 import java.util.List;
15 13 import java.util.Map;
... ... @@ -32,13 +30,11 @@ public class ErpReadTool {
32 30 private static final int MAX_COLS = 6;
33 31  
34 32 private final ErpClient erp;
35   - private final JdbcTemplate jdbc;
36 33 private final FormResolverService resolver;
37 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 37 this.erp = erp;
41   - this.jdbc = jdbc;
42 38 this.resolver = resolver;
43 39 this.identity = identity;
44 40 }
... ... @@ -129,7 +125,7 @@ public class ErpReadTool {
129 125 return "你没有访问该表单的权限。";
130 126 }
131 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 130 JsonNode root;
135 131 try {
... ... @@ -151,7 +147,7 @@ public class ErpReadTool {
151 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 151 List<String> cols = pickColumns(data.get(0), labels);
156 152  
157 153 String scope;
... ... @@ -179,45 +175,6 @@ public class ErpReadTool {
179 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 178 /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */
222 179 private List<String> pickColumns(JsonNode first, Map<String, String> labels) {
223 180 List<String> labeled = new ArrayList<>();
... ...
src/main/java/com/xly/tool/KgQueryTool.java
... ... @@ -9,13 +9,8 @@ import java.util.List;
9 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 15 * <p>表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。
21 16 */
... ... @@ -67,79 +62,6 @@ public class KgQueryTool {
67 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 65 private static String str(Object o) {
144 66 return o == null ? "" : o.toString();
145 67 }
... ...
src/main/java/com/xly/tool/ProposeWriteTool.java
... ... @@ -77,51 +77,13 @@ public class ProposeWriteTool {
77 77 if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
78 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 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 87 null, null, null, cancel ? "cancel" : "toVoid", description);
126 88  
127 89 Map<String, Object> out = new LinkedHashMap<>();
... ... @@ -138,71 +100,30 @@ public class ProposeWriteTool {
138 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 109 // 2) 字段中文名 -> 技术列名
154 110 String field = queryOne(
155 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 113 if (field == null) {
158 114 field = queryOne(
159 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 118 if (field == null) {
163 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 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 127 field, fieldChinese, oldValue, newValue, description);
207 128  
208 129 Map<String, Object> out = new LinkedHashMap<>();
... ... @@ -218,51 +139,13 @@ public class ProposeWriteTool {
218 139 if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
219 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 150 Map<String, Object> out = new LinkedHashMap<>();
268 151 out.put("opId", opId);
... ... @@ -386,52 +269,14 @@ public class ProposeWriteTool {
386 269 if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
387 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 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 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 280 null, null, null, String.valueOf(iFlag), description);
436 281  
437 282 Map<String, Object> out = new LinkedHashMap<>();
... ... @@ -630,6 +475,70 @@ public class ProposeWriteTool {
630 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 542 private static String str(Object o) {
634 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 5 import com.xly.agent.AgentIdentity;
6 6 import com.xly.agent.Intent;
7 7 import com.xly.agent.ReActAgent;
8   -import com.xly.agent.ToolScope;
9 8 import com.xly.config.AgentFactory;
10 9 import com.xly.service.AuthzService;
11 10 import com.xly.service.ConversationService;
... ... @@ -36,16 +35,15 @@ import java.util.concurrent.Executors;
36 35 /**
37 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 40 * <ul>
42 41 * <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单
43 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 45 * </ul>
48   - * 收窄工具集 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。
  46 + * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。
49 47 *
50 48 * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、
51 49 * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。
... ... @@ -119,12 +117,12 @@ public class AgentChatController {
119 117  
120 118 /** 意图门 + 确定性路由。 */
121 119 private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {
122   - // 0) 表单提交 → 直接走写(执行 proposeWrite(action=create)),不再重新分类。
  120 + // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。
123 121 if (userInput.contains(FORM_SUBMIT_MARK)) {
124   - runAgent(emitter, convId, identity, ToolScope.WRITE, userInput);
  122 + runAgent(emitter, convId, identity, userInput);
125 123 return;
126 124 }
127   - // 1) 意图门(失败时返回 UNCLEAR,走兜底)。
  125 + // 1) 意图门(失败时返回 其他,走兜底)。
128 126 Intent it = intentService.classify(userInput);
129 127 log.info("intent(conv={}): {} / {} / entities={} / missing={}",
130 128 convId, it.intent, it.danju, it.describeEntities(), it.missing);
... ... @@ -134,24 +132,18 @@ public class AgentChatController {
134 132 if (handleCreate(emitter, convId, identity, userInput, it)) {
135 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 137 return;
140   - case Intent.UPDATE:
141   - case Intent.DELETE:
142   - case Intent.EXAMINE:
  138 + case Intent.OPERATE:
143 139 handleWrite(emitter, convId, identity, userInput, it);
144 140 return;
145 141 case Intent.QUERY:
146   - runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it));
  142 + runAgent(emitter, convId, identity, ground(userInput, it));
147 143 return;
148   - case Intent.CHAT:
149   - runAgent(emitter, convId, identity, ToolScope.READ, userInput);
150   - return;
151   - case Intent.UNCLEAR:
152 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 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 196 try {
206   - ReActAgent agent = agentFactory.build(identity, scope);
  197 + ReActAgent agent = agentFactory.build(identity);
207 198 TokenStream ts = agent.chat(convId, text);
208 199 ts.onPartialResponse(token -> send(emitter, "token", token))
209 200 .onToolExecuted(te -> handleToolExecuted(emitter, convId, te))
... ... @@ -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 244 private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity,
... ... @@ -256,7 +247,7 @@ public class AgentChatController {
256 247 log.info("write-slots(conv={}): entity={} record={} field={} newValue={}",
257 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 251 String ent = pickEntity(w.entityType, it.danju);
261 252  
262 253 // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环)
... ... @@ -312,16 +303,15 @@ public class AgentChatController {
312 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 308 String t = text == null ? "" : text;
318 309 if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine";
319 310 if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid";
320 311 if (t.contains("作废")) return "invalid";
321 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 315 return "update";
326 316 }
327 317  
... ...
src/main/resources/application.yml
... ... @@ -66,8 +66,6 @@ llm:
66 66 base-url: http://112.82.245.194:41434/v1
67 67 api-key: ollama # Ollama 不校验(任意非空);云端填真实 key
68 68 chat-model: qwen3.6-27b-iq3:latest
69   - # SQL/代码模型(QueryTool NL2SQL 专用)
70   - sql-model: qwen3.6-27b-iq3:latest
71 69  
72 70 erp:
73 71 baseurl: http://118.178.19.35:8080/xlyEntry_saas
... ...