diff --git a/pom.xml b/pom.xml index 5bb93b8..2b7fbcf 100644 --- a/pom.xml +++ b/pom.xml @@ -101,13 +101,6 @@ langchain4j-open-ai ${langchain4j.version} - - - - com.github.jsqlparser - jsqlparser - 4.9 - diff --git a/src/main/java/com/xly/agent/Intent.java b/src/main/java/com/xly/agent/Intent.java index 5d41096..a412a85 100644 --- a/src/main/java/com/xly/agent/Intent.java +++ b/src/main/java/com/xly/agent/Intent.java @@ -7,19 +7,17 @@ import java.util.List; * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 * *

由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 - * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**, - * 而不是让弱模型在全部 9 个工具里盲选——这是治理「理解错/选错工具」的核心。 + * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**: + * 新增→确定性表单、操作已有单据→写槽位抽取+确定性 proposeWrite、查询/其他→ReAct agent。 + * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 */ public class Intent { - /** 操作意图。 */ + /** 操作意图(4 类)。 */ public static final String QUERY = "查询"; public static final String CREATE = "新增"; - public static final String UPDATE = "修改"; - public static final String DELETE = "删除"; - public static final String EXAMINE = "审核"; - public static final String CHAT = "闲聊"; - public static final String UNCLEAR = "不清楚"; + public static final String OPERATE = "操作已有单据"; + public static final String OTHER = "其他"; public static class Entity { public String value; @@ -36,7 +34,7 @@ public class Intent { public String newValue = ""; // 修改后的新值(删除/审核留空) } - public String intent = UNCLEAR; + public String intent = OTHER; public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 public List entities = new ArrayList<>(); public List missing = new ArrayList<>(); // 完成该意图还缺的关键信息 diff --git a/src/main/java/com/xly/agent/ReActAgent.java b/src/main/java/com/xly/agent/ReActAgent.java index 8976164..8d1191c 100644 --- a/src/main/java/com/xly/agent/ReActAgent.java +++ b/src/main/java/com/xly/agent/ReActAgent.java @@ -11,8 +11,8 @@ import dev.langchain4j.service.UserMessage; * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。 * *

但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService}) - * 判类,再由 {@link com.xly.config.AgentFactory} 按 {@link ToolScope} 只把该意图相关的工具装进本实例 - * (新增意图更是完全绕过 ReAct,走确定性 collectForm 路径)。本接口只负责「给定这批工具,自己决定怎么用」。 + * 判类,新增/操作已有单据走确定性路径(collectForm / proposeWrite)完全绕过 ReAct; + * 本接口只负责「给定固定的 6 个工具,自己决定怎么用」。 * *

返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 */ diff --git a/src/main/java/com/xly/agent/ToolScope.java b/src/main/java/com/xly/agent/ToolScope.java deleted file mode 100644 index 8c041ee..0000000 --- a/src/main/java/com/xly/agent/ToolScope.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.xly.agent; - -/** - * 按意图收窄「本轮可见的工具集」(架构 §5 的 tool scoping)。 - * - *

依据:单次 ReAct 的选工具准确率随工具数量上升而骤降(业界经验:50 个工具 ~94% → 200 个 ~64%)。 - * 弱模型(qwen3:14b Q4)一次性面对全部 9 个语义重叠的工具时,频繁选错族(把「查询」当「新增」)。 - * 于是先用意图门判类,再**只暴露该意图相关的 5 个工具**给 ReAct 执行——大幅降低选错概率。 - */ -public enum ToolScope { - /** 读:findForms/kgSearch/readFormData/lookupRecord/queryData(+askUser/loadSkill)。 */ - READ, - /** 写:collectForm/proposeWrite(action=…)(+readFormData·lookupRecord 定位、askUser/loadSkill)。 */ - WRITE, - /** 兜底:全部 9 个工具(意图门失败时用)。 */ - FULL -} diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index 297dbfc..62bfbb1 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -3,22 +3,16 @@ package com.xly.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.agent.ReActAgent; -import com.xly.agent.ToolScope; -import com.xly.service.AuditService; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import com.xly.service.OpService; -import com.xly.service.SkillService; import com.xly.service.SystemPromptService; import com.xly.tool.ErpReadTool; import com.xly.tool.FormCollectTool; import com.xly.tool.InteractionTool; import com.xly.tool.KgQueryTool; import com.xly.tool.ProposeWriteTool; -import com.xly.tool.QueryTool; -import com.xly.tool.SkillTool; import dev.langchain4j.memory.chat.MessageWindowChatMemory; -import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.service.AiServices; import org.springframework.beans.factory.annotation.Qualifier; @@ -30,16 +24,16 @@ import org.springframework.stereotype.Component; * *

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

无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、 - * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。 + *

工具集**固定 6 个**(findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser), + * 不再按意图分范围:n=50 基准显示分范围收不到准确率收益,反而破坏 KV-cache 前缀稳定性。 + * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。 */ @Component public class AgentFactory { private final StreamingChatModel streamingModel; - private final ChatModel sqlModel; private final RedisChatMemoryStore memoryStore; private final SystemPromptService systemPromptService; @@ -48,22 +42,16 @@ public class AgentFactory { private final FormResolverService resolver; private final OpService ops; private final ObjectMapper mapper; - private final AuditService audit; - private final SkillService skillService; private final KgQueryTool kgQueryTool; - private final SkillTool skillTool; private final InteractionTool interactionTool; public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, - @Qualifier("sqlChatModel") ChatModel sqlModel, RedisChatMemoryStore memoryStore, SystemPromptService systemPromptService, ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, - ObjectMapper mapper, AuditService audit, SkillService skillService, - KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) { + ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) { this.streamingModel = streamingModel; - this.sqlModel = sqlModel; this.memoryStore = memoryStore; this.systemPromptService = systemPromptService; this.erp = erp; @@ -71,45 +59,19 @@ public class AgentFactory { this.resolver = resolver; this.ops = ops; this.mapper = mapper; - this.audit = audit; - this.skillService = skillService; this.kgQueryTool = kgQueryTool; - this.skillTool = skillTool; this.interactionTool = interactionTool; } - /** 兜底:全部工具(意图门失败时用)。 */ - public ReActAgent build(AgentIdentity identity) { - return build(identity, ToolScope.FULL); - } - /** - * 按意图范围组装 ReAct agent:只暴露该范围相关的工具 + 对应版本的 system prompt。 - * - *

收窄可见工具(3-5 个)显著提升弱模型的选工具准确率;{@code maxSequentialToolsInvocations} + * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations} * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 */ - public ReActAgent build(AgentIdentity identity, ToolScope scope) { - String systemPrompt = systemPromptService.buildPrompt(scope); - - ErpReadTool readTool = new ErpReadTool(erp, jdbc, resolver, identity); - Object[] tools; - switch (scope) { - case READ: - tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, - new QueryTool(sqlModel, jdbc, audit, identity, resolver)}; - break; - case WRITE: - tools = new Object[]{skillTool, interactionTool, readTool, - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), - new FormCollectTool(jdbc, resolver, identity, mapper)}; - break; - default: // FULL - tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), - new QueryTool(sqlModel, jdbc, audit, identity, resolver), - new FormCollectTool(jdbc, resolver, identity, mapper)}; - } + public ReActAgent build(AgentIdentity identity) { + Object[] tools = new Object[]{kgQueryTool, interactionTool, + new ErpReadTool(erp, resolver, identity), + new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), + new FormCollectTool(jdbc, resolver, identity, mapper)}; return AiServices.builder(ReActAgent.class) .streamingChatModel(streamingModel) @@ -120,7 +82,7 @@ public class AgentFactory { .maxMessages(30) .chatMemoryStore(memoryStore) .build()) - .systemMessageProvider(memoryId -> systemPrompt) + .systemMessageProvider(memoryId -> systemPromptService.prompt()) .build(); } diff --git a/src/main/java/com/xly/config/ModelConfig.java b/src/main/java/com/xly/config/ModelConfig.java index 3044073..ee56f1e 100644 --- a/src/main/java/com/xly/config/ModelConfig.java +++ b/src/main/java/com/xly/config/ModelConfig.java @@ -3,44 +3,13 @@ package com.xly.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import dev.langchain4j.model.chat.ChatModel; -import dev.langchain4j.model.openai.OpenAiChatModel; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; -import java.time.Duration; -import java.util.List; - @Configuration public class ModelConfig { - @Value("${llm.base-url}") - private String baseUrl; - - @Value("${llm.api-key:ollama}") - private String apiKey; - - @Value("${llm.sql-model}") - private String sqlModelName; - - /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */ - @Bean("sqlChatModel") - public ChatModel sqlChatModel(TracingChatModelListener tracingListener) { - return OpenAiChatModel.builder() - .baseUrl(baseUrl) - .apiKey(apiKey) - .modelName(sqlModelName) - .temperature(0.0) - .topP(0.95) - .reasoningEffort("none") - .listeners(List.of(tracingListener)) - .timeout(Duration.ofSeconds(120)) - .maxRetries(3) - .build(); - } - @Bean @Primary public ObjectMapper objectMapper() { diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index f699662..248b089 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -83,6 +83,15 @@ public class FormResolverService { "ORDER BY iFormUses DESC LIMIT 1", table); } + /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */ + public String resolveNameFieldByFormId(String formId) { + return queryOne( + "SELECT fd.sField FROM viw_ai_useful_forms af " + + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + + "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + + "ORDER BY fd.iFormUses DESC LIMIT 1", formId); + } + // ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。 private static final Set SYS_EXACT = Set.of( "sId", "sBrandsId", "sSubsidiaryId", "sMakePerson", "sFormId", "sBillNo", "iIncrement", "iOrder", diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java index 8ca800a..0d67165 100644 --- a/src/main/java/com/xly/service/IntentService.java +++ b/src/main/java/com/xly/service/IntentService.java @@ -11,14 +11,14 @@ import java.util.List; import java.util.Map; /** - * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 + * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图(4 类), 单据类型, 带角色的实体, 缺失信息}。 * - *

只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下 - * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」 - * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的 - * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。 + *

只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。提示只给**类定义** + * (含有界的行业常识,如 定制品问价=新建报价),不写逐案纠错规则——错误种类无穷, + * 按案例打补丁写不完;观察到的错例进回归基准(bench50)而不是提示。 + * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 * - *

失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。 + *

失败(模型不可达/JSON 异常)时返回 intent=其他 的空结果,调用方降级到通用 agent,绝不中断。 */ @Service public class IntentService { @@ -26,18 +26,17 @@ public class IntentService { private static final Logger log = LoggerFactory.getLogger(IntentService.class); private static final String SYSTEM = - "你是印刷/包装 ERP 的意图与实体抽取器。给你一句用户话,判断其操作意图" - + "(查询/新增/修改/删除/审核/闲聊/不清楚)、涉及的单据或实体类型,并抽取句中出现的实体及其业务角色" - + "(客户/产品/物料/供应商/数量/尺寸/日期/金额)。规则:" - + "①『报价X』『给X报价』里,若 X 是物品(如 纸盒/彩盒/画册/小册子)则 X 是**产品**,不是客户;" - + "只有明确出现公司/客户名(如『给苏州华为报价』)时才把它当**客户**。" - + "②对某个产品/规格问价——『多少钱』『什么价』『报个价』『报价』『价格是多少』——在印刷/包装行业" - + "**通常是要新建一张报价单**(价格由系统核价算出,系统里没有该定制活的现成价),intent=**新增**、单据=**报价**;" - + "只有明确查看/统计**已存在**的单据(给了单号,或说『查已有报价/列出报价单/上月报价额』)才是查询。" - + "③『有多少X/查一下X/X的电话是多少』这类问既有数据是**查询**,不是新增。" - + "④只说一个名词(如『报价』『纸盒』)而无动作时,intent=不清楚。" - + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。" - + "只输出 JSON,不要解释。"; + "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n" + + "意图定义:\n" + + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n" + + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)" + + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n" + + "- 操作已有单据:对系统里已存在的某条单据/记录做修改、作废/删除、审核/反审核、复原。\n" + + "- 其他:闲聊、问候、与 ERP 业务无关,或信息太少无法判断(如只说一个名词)。\n" + + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n" + + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价)," + + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。" + + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。"; private final LlmJsonClient llm; @@ -45,7 +44,7 @@ public class IntentService { this.llm = llm; } - /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */ + /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ public Intent classify(String utterance) { Intent out = new Intent(); if (utterance == null || utterance.isBlank()) { @@ -53,10 +52,10 @@ public class IntentService { } JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); if (n == null) { - log.warn("intent classify fell back to UNCLEAR (model unavailable)"); + log.warn("intent classify fell back to 其他 (model unavailable)"); return out; } - String intent = n.path("intent").asText(Intent.UNCLEAR); + String intent = n.path("intent").asText(Intent.OTHER); out.intent = normalizeIntent(intent); out.danju = n.path("danju").asText(""); JsonNode es = n.path("entities"); @@ -117,19 +116,16 @@ public class IntentService { } private static String normalizeIntent(String s) { - if (s == null) return Intent.UNCLEAR; + if (s == null) return Intent.OTHER; s = s.trim(); switch (s) { case Intent.QUERY: case Intent.CREATE: - case Intent.UPDATE: - case Intent.DELETE: - case Intent.EXAMINE: - case Intent.CHAT: - case Intent.UNCLEAR: + case Intent.OPERATE: + case Intent.OTHER: return s; default: - return Intent.UNCLEAR; + return Intent.OTHER; } } @@ -145,7 +141,7 @@ public class IntentService { Map props = new LinkedHashMap<>(); props.put("intent", Map.of("type", "string", - "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚"))); + "enum", List.of("查询", "新增", "操作已有单据", "其他"))); props.put("danju", Map.of("type", "string")); props.put("entities", Map.of("type", "array", "items", entityItem)); props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java deleted file mode 100644 index ec7ecff..0000000 --- a/src/main/java/com/xly/service/SkillService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.xly.service; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; - -/** - * Skill 注册表服务(架构 §6)。 - * - *

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

新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。 - * 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。 - * 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。 + *

所有轮次共用同一份提示 + 同一组 6 个工具(findForms/readFormData/lookupRecord/ + * collectForm/proposeWrite/askUser):n=50 基准显示按意图分版收不到准确率收益, + * 反而破坏 KV-cache 前缀稳定性。提示遵循弱模型提示工程:关键规则前置、正向表述、 + * 工具清单短;域知识(业务域地图、业务常识)常驻,不做按类分版。 */ @Service public class SystemPromptService { private final JdbcTemplate jdbc; - private final SkillService skills; - private volatile String readPrompt; - private volatile String writePrompt; - private volatile String fullPrompt; + private volatile String prompt; - public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { + public SystemPromptService(JdbcTemplate jdbc) { this.jdbc = jdbc; - this.skills = skills; } private static final String HEADER = @@ -34,93 +30,38 @@ public class SystemPromptService { + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; - public String buildPrompt(ToolScope scope) { - switch (scope) { - case READ: - if (readPrompt == null) readPrompt = renderRead(); - return readPrompt; - case WRITE: - if (writePrompt == null) writePrompt = renderWrite(); - return writePrompt; - default: - if (fullPrompt == null) fullPrompt = renderFull(); - return fullPrompt; + public String prompt() { + String p = prompt; + if (p == null) { + p = render(); + prompt = p; } + return p; } - private String renderRead() { + private String render() { return HEADER + """ - 本轮任务是**查询 / 读取**数据(不做任何写操作)。 - 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 %s - 【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做: - %s + 【业务常识】 + - 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。 + - 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。 + 【可用工具(只有这些)】 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 - - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。 - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 - - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。 - - queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。 - - loadSkill(name)、askUser(question, options?)。 - - 【准则】 - 1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。 - 2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。 - 3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。 - """.formatted(renderDomainMap(), renderSkills()); - } - - private String renderWrite() { - return HEADER + """ - - 本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、 - 绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。 - - 【可用工具(只有这些)】 - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。 - - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作: - · create=新增(用 fieldsJson,通常在用户表单提交后) - · update=改某字段(用 recordKeyword+fieldChinese+newValue) - · invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废 - · examine=审核;cancelExamine=销审/反审核 - · delete=物理删除(明细行/极少用,业务单据别用) - 它自行定位主表与记录,无需先 findForms。 - - lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。 - - askUser(question, options?)、loadSkill(name)。 + - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 + - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 + - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 + - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 【准则】 - 1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。 - 2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。 - 3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。 - """.formatted(); - } - - private String renderFull() { - // 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。 - return HEADER + """ - - 【业务域地图】 - %s - 【可用技能】命中「何时用」时先 loadSkill(技能名): - %s - 【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData; - 写(人在环,仅提议):collectForm,以及唯一的写工具 - proposeWrite(action=create|update|invalid|cancelInvalid|examine|cancelExamine|delete, ...); - 以及 askUser/loadSkill。 - - 【准则】 - 1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。 - 2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm,用户提交后再 proposeWrite(action=create)。删除业务单据用 action=invalid(作废),不要用 delete。 - 3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。 - 4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。 - """.formatted(renderDomainMap(), renderSkills()); - } - - private String renderSkills() { - String digest = skills.promptDigest(); - return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest; + 1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。 + 2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。 + 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。 + 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。 + """.formatted(renderDomainMap()); } private String renderDomainMap() { diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index 7829e18..97a8fe2 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -6,10 +6,8 @@ import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; -import org.springframework.jdbc.core.JdbcTemplate; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -32,13 +30,11 @@ public class ErpReadTool { private static final int MAX_COLS = 6; private final ErpClient erp; - private final JdbcTemplate jdbc; private final FormResolverService resolver; private final AgentIdentity identity; - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity) { + public ErpReadTool(ErpClient erp, FormResolverService resolver, AgentIdentity identity) { this.erp = erp; - this.jdbc = jdbc; this.resolver = resolver; this.identity = identity; } @@ -129,7 +125,7 @@ public class ErpReadTool { return "你没有访问该表单的权限。"; } String kw = (keyword == null) ? "" : keyword.trim(); - String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim()); + String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); JsonNode root; try { @@ -151,7 +147,7 @@ public class ErpReadTool { return "该表单当前没有数据(共 " + total + " 条)。"; } - Map labels = loadLabels(formId.trim()); + Map labels = resolver.fieldLabels(formId.trim()); List cols = pickColumns(data.get(0), labels); String scope; @@ -179,45 +175,6 @@ public class ErpReadTool { return sb.toString(); } - /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ - private Map loadLabels(String formId) { - Map m = new HashMap<>(); - try { - List> rows = jdbc.queryForList( - "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh " + - "FROM viw_ai_useful_forms af " + - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + - "WHERE af.sFormId = ? GROUP BY fd.sField", formId); - for (Map r : rows) { - Object f = r.get("f"); - Object zh = r.get("zh"); - if (f != null && zh != null) { - m.put(f.toString(), zh.toString()); - } - } - } catch (Exception ignore) { - // 无字典时退化为技术列名 - } - return m; - } - - /** 猜测该表单的"名称"字段(用于按关键词过滤):数据源表里以 Name 结尾、使用最广的字段。 */ - private String resolveNameField(String formId) { - try { - List> rows = jdbc.queryForList( - "SELECT fd.sField AS f FROM viw_ai_useful_forms af " + - "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + - "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + - "ORDER BY fd.iFormUses DESC LIMIT 1", formId); - if (!rows.isEmpty() && rows.get(0).get("f") != null) { - return rows.get(0).get("f").toString(); - } - } catch (Exception ignore) { - // 无字典时不过滤 - } - return null; - } - /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ private List pickColumns(JsonNode first, Map labels) { List labeled = new ArrayList<>(); diff --git a/src/main/java/com/xly/tool/KgQueryTool.java b/src/main/java/com/xly/tool/KgQueryTool.java index 004b6e0..c08523b 100644 --- a/src/main/java/com/xly/tool/KgQueryTool.java +++ b/src/main/java/com/xly/tool/KgQueryTool.java @@ -9,13 +9,8 @@ import java.util.List; import java.util.Map; /** - * 知识图谱查询工具(架构 §5 #3 KgSearch / §11)。两个只读能力: - *

+ * 表单目录检索工具:{@link #findForms} 按关键词检索 ERP 业务表单目录 + * (视图 {@code viw_ai_useful_forms},1748 张有用业务表单),即「表单名 → formId/moduleId」定位。 * *

表单目录是**全局元数据**(对所有品牌一致),不涉及行级/租户数据,因此不需要租户过滤。 */ @@ -67,79 +62,6 @@ public class KgQueryTool { return sb.toString(); } - @Tool("查知识图谱(KG):某表单的**上下游流转/相邻单据**(L2)与某字段**在哪些表/列**(L3)。" - + "用于弄清「这张单从哪来、到哪去、和谁相关」或「某个字段落在哪张表」。入参 = 表单名或字段中文名关键词。") - public String kgSearch(@P("表单名或字段中文名关键词,如 采购订单 / 送货 / 单价 / 数量") String keyword) { - if (keyword == null || keyword.isBlank()) { - return "请提供一个表单名或字段中文名关键词。"; - } - String kw = keyword.trim(); - String like = "%" + kw + "%"; - StringBuilder sb = new StringBuilder(); - - // L2:表单邻居 / 上下游流转 - List> flow = safeQuery( - "SELECT sFormTitle, sDomain, iUpstream, iDownstream, sUpForms, sDownForms, sRefTables " + - "FROM viw_kg_form_neighbors WHERE sFormTitle LIKE ? " + - "ORDER BY (COALESCE(iUpstream,0)+COALESCE(iDownstream,0)) DESC LIMIT 5", like); - if (!flow.isEmpty()) { - sb.append("【单据流转 / 相邻单据】\n"); - for (Map r : flow) { - sb.append("- ").append(str(r.get("sFormTitle"))) - .append("(域:").append(str(r.get("sDomain"))).append(")"); - String up = clip(str(r.get("sUpForms")), 80); - String down = clip(str(r.get("sDownForms")), 80); - if (!up.isBlank()) sb.append("\n 上游←:").append(up); - if (!down.isBlank()) sb.append("\n 下游→:").append(down); - String ref = clip(str(r.get("sRefTables")), 80); - if (!ref.isBlank()) sb.append("\n 引用表:").append(ref); - sb.append('\n'); - } - } - - // L3:字段 -> 表/列(按词查,绝不整表 dump) - List> fields = safeQuery( - "SELECT sTable, sField, sChinese, sFkTable FROM viw_kg_field_dict " + - "WHERE sChinese LIKE ? AND sTable NOT LIKE 'viw%' " + - "ORDER BY iFormUses DESC LIMIT 8", like); - if (!fields.isEmpty()) { - sb.append("\n【字段所在表/列】\n"); - for (Map r : fields) { - sb.append("- ").append(str(r.get("sChinese"))) - .append(" = ").append(str(r.get("sTable"))).append(".").append(str(r.get("sField"))); - String fk = str(r.get("sFkTable")); - if (fk != null && !fk.isBlank() && !"null".equalsIgnoreCase(fk)) { - sb.append("(外键→").append(fk).append(")"); - } - sb.append('\n'); - } - } - - if (sb.length() == 0) { - return "知识图谱里没有与「" + kw + "」直接相关的流转或字段。可以换个更常见的单据名或字段名。"; - } - return sb.toString(); - } - - private List> safeQuery(String sql, Object... args) { - try { - return jdbc.queryForList(sql, args); - } catch (Exception e) { - return List.of(); - } - } - - private static String clip(String s, int max) { - if (s == null) { - return ""; - } - String t = s.trim(); - if ("null".equalsIgnoreCase(t)) { - return ""; - } - return t.length() > max ? t.substring(0, max) + "…" : t; - } - private static String str(Object o) { return o == null ? "" : o.toString(); } diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 38ed5e3..ccdd491 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -77,51 +77,13 @@ public class ProposeWriteTool { if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少实体类型或记录名称。"); } - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - return err("找不到「" + entityKeyword + "」对应的可操作主表。"); - } - String formId = str(form.get("sFormId")); - String moduleId = str(form.get("sModuleId")); - String table = str(form.get("sDataSource")); - if (!identity.canAccessModule(moduleId)) { - return err("你没有操作「" + entityKeyword + "」的权限。"); - } - String nameField = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + - "ORDER BY iFormUses DESC LIMIT 1", table); - JsonNode root; - try { - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); - } catch (Exception e) { - return err("定位记录失败:" + e.getMessage()); - } - if (root.path("code").asInt(0) < 0) { - return err("定位记录失败:" + root.path("msg").asText("未知错误")); - } - JsonNode rows = root.path("dataset").path("rows"); - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; - int n = (data != null && data.isArray()) ? data.size() : 0; - if (n == 0) { - return err("没有找到名称含「" + recordKeyword + "」的记录。"); - } - if (n > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < data.size() && i < 5; i++) { - if (i > 0) names.append("、"); - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); - } - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); - } - JsonNode rec = data.get(0); - String billId = rec.path("sId").asText(null); - if (isBlank(billId)) { - return err("定位到的记录缺少主键 sId,无法" + verb + "。"); + Located l = locateRecord(entityKeyword, recordKeyword, verb); + if (l.error != null) { + return l.error; } - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); - String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; + String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid - String opId = ops.createDraft(identity.userId(), "invalid", formId, moduleId, table, billId, + String opId = ops.createDraft(identity.userId(), "invalid", l.formId, l.moduleId, l.table, l.billId, null, null, null, cancel ? "cancel" : "toVoid", description); Map out = new LinkedHashMap<>(); @@ -138,71 +100,30 @@ public class ProposeWriteTool { return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); } - // 1) 定位可修改的主表(该实体名下、table 类型、最常用的一张) - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - return err("找不到「" + entityKeyword + "」对应的可修改主表。"); - } - String formId = str(form.get("sFormId")); - String moduleId = str(form.get("sModuleId")); - String table = str(form.get("sDataSource")); - if (!identity.canAccessModule(moduleId)) { - return err("你没有修改「" + entityKeyword + "」的权限。"); + // 1) 定位主表 + 唯一记录 + Located l = locateRecord(entityKeyword, recordKeyword, "修改"); + if (l.error != null) { + return l.error; } // 2) 字段中文名 -> 技术列名 String field = queryOne( "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", - table, fieldChinese.trim()); + l.table, fieldChinese.trim()); if (field == null) { field = queryOne( "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", - table, "%" + fieldChinese.trim() + "%"); + l.table, "%" + fieldChinese.trim() + "%"); } if (field == null) { return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); } + String oldValue = l.rec.path(field).asText(""); - // 3) 定位唯一记录 + 旧值(用名称字段过滤) - String nameField = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + - "ORDER BY iFormUses DESC LIMIT 1", table); - JsonNode root; - try { - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); - } catch (Exception e) { - return err("定位记录时读取失败:" + e.getMessage()); - } - if (root.path("code").asInt(0) < 0) { - return err("定位记录失败:" + root.path("msg").asText("未知错误")); - } - JsonNode rows = root.path("dataset").path("rows"); - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; - int n = (data != null && data.isArray()) ? data.size() : 0; - if (n == 0) { - return err("没有找到名称含「" + recordKeyword + "」的记录,无法修改。"); - } - if (n > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < data.size() && i < 5; i++) { - if (i > 0) names.append("、"); - names.append(data.get(i).path(nameField == null ? "" : nameField).asText("")); - } - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只改其中一条。"); - } - - JsonNode rec = data.get(0); - String billId = rec.path("sId").asText(null); - if (isBlank(billId)) { - return err("定位到的记录缺少主键 sId,无法安全修改。"); - } - String oldValue = rec.path(field).asText(""); - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); - - // 4) 暂存 draft(不执行) - String description = "将【" + recordName + "】的【" + fieldChinese + "】" + // 3) 暂存 draft(不执行) + String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; - String opId = ops.createDraft(identity.userId(), "update", formId.trim(), moduleId.trim(), table, billId, + String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId, field, fieldChinese, oldValue, newValue, description); Map out = new LinkedHashMap<>(); @@ -218,51 +139,13 @@ public class ProposeWriteTool { if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少实体类型或记录名称。"); } - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - return err("找不到「" + entityKeyword + "」对应的可操作主表。"); - } - String formId = str(form.get("sFormId")); - String moduleId = str(form.get("sModuleId")); - String table = str(form.get("sDataSource")); - if (!identity.canAccessModule(moduleId)) { - return err("你没有操作「" + entityKeyword + "」的权限。"); - } - String nameField = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + - "ORDER BY iFormUses DESC LIMIT 1", table); - JsonNode root; - try { - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); - } catch (Exception e) { - return err("定位记录失败:" + e.getMessage()); - } - if (root.path("code").asInt(0) < 0) { - return err("定位记录失败:" + root.path("msg").asText("未知错误")); - } - JsonNode rows = root.path("dataset").path("rows"); - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; - int n = (data != null && data.isArray()) ? data.size() : 0; - if (n == 0) { - return err("没有找到名称含「" + recordKeyword + "」的记录。"); + Located l = locateRecord(entityKeyword, recordKeyword, "删除"); + if (l.error != null) { + return l.error; } - if (n > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < data.size() && i < 5; i++) { - if (i > 0) names.append("、"); - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); - } - return err("匹配到多条记录(" + names + "),请提供更精确的名称,只删其中一条。"); - } - JsonNode rec = data.get(0); - String billId = rec.path("sId").asText(null); - if (isBlank(billId)) { - return err("定位到的记录缺少主键 sId,无法安全删除。"); - } - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); - String description = "删除【" + recordName + "】(" + entityKeyword + ")"; - String opId = ops.createDraft(identity.userId(), "delete", formId, moduleId, table, billId, - null, null, recordName, null, description); + String description = "删除【" + l.recordName + "】(" + entityKeyword + ")"; + String opId = ops.createDraft(identity.userId(), "delete", l.formId, l.moduleId, l.table, l.billId, + null, null, l.recordName, null, description); Map out = new LinkedHashMap<>(); out.put("opId", opId); @@ -386,52 +269,14 @@ public class ProposeWriteTool { if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少单据类型或单号/名称。"); } - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - return err("找不到「" + entityKeyword + "」对应的可审核单据主表。"); - } - String formId = str(form.get("sFormId")); - String moduleId = str(form.get("sModuleId")); - String table = str(form.get("sDataSource")); - if (!identity.canAccessModule(moduleId)) { - return err("你没有审核「" + entityKeyword + "」的权限。"); - } - String nameField = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + - "ORDER BY iFormUses DESC LIMIT 1", table); - JsonNode root; - try { - root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); - } catch (Exception e) { - return err("定位单据失败:" + e.getMessage()); - } - if (root.path("code").asInt(0) < 0) { - return err("定位单据失败:" + root.path("msg").asText("未知错误")); - } - JsonNode rows = root.path("dataset").path("rows"); - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; - int n = (data != null && data.isArray()) ? data.size() : 0; - if (n == 0) { - return err("没有找到含「" + recordKeyword + "」的单据。"); - } - if (n > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < data.size() && i < 5; i++) { - if (i > 0) names.append("、"); - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); - } - return err("匹配到多条单据(" + names + "),请提供更精确的单号,只审核其中一条。"); - } - JsonNode rec = data.get(0); - String billId = rec.path("sId").asText(null); - if (isBlank(billId)) { - return err("定位到的单据缺少主键 sId,无法审核。"); - } - String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); String verb = iFlag == 1 ? "审核" : "反审核"; - String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; + Located l = locateRecord(entityKeyword, recordKeyword, verb); + if (l.error != null) { + return l.error; + } + String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) - String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, + String opId = ops.createDraft(identity.userId(), "examine", l.formId, l.moduleId, l.table, l.billId, null, null, null, String.valueOf(iFlag), description); Map out = new LinkedHashMap<>(); @@ -630,6 +475,70 @@ public class ProposeWriteTool { return resolver.resolveMasterForm(entityKeyword); } + /** {@link #locateRecord} 的结果:error 非空表示失败(已是 err() JSON);否则携带定位到的主表与唯一记录。 */ + private static final class Located { + String error; + String formId; + String moduleId; + String table; + JsonNode rec; + String billId; + String recordName; + } + + /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称字段模糊匹配 → 0 条/多条/缺主键报错。 */ + private Located locateRecord(String entityKeyword, String recordKeyword, String verb) { + Located l = new Located(); + Map form = resolveForm(entityKeyword.trim()); + if (form == null) { + l.error = err("找不到「" + entityKeyword + "」对应的可操作主表。"); + return l; + } + l.formId = str(form.get("sFormId")); + l.moduleId = str(form.get("sModuleId")); + l.table = str(form.get("sDataSource")); + if (!identity.canAccessModule(l.moduleId)) { + l.error = err("你没有" + verb + "「" + entityKeyword + "」的权限。"); + return l; + } + String nameField = resolver.resolveNameField(l.table); + JsonNode root; + try { + root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + l.error = err("定位记录失败:" + e.getMessage()); + return l; + } + if (root.path("code").asInt(0) < 0) { + l.error = err("定位记录失败:" + root.path("msg").asText("未知错误")); + return l; + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + l.error = err("没有找到名称含「" + recordKeyword + "」的记录。"); + return l; + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); + } + l.error = err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); + return l; + } + l.rec = data.get(0); + l.billId = l.rec.path("sId").asText(null); + if (isBlank(l.billId)) { + l.error = err("定位到的记录缺少主键 sId,无法" + verb + "。"); + return l; + } + l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword); + return l; + } + private static String str(Object o) { return o == null ? null : o.toString(); } diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java deleted file mode 100644 index 28a8ae0..0000000 --- a/src/main/java/com/xly/tool/QueryTool.java +++ /dev/null @@ -1,416 +0,0 @@ -package com.xly.tool; - -import com.xly.agent.AgentIdentity; -import com.xly.service.AuditService; -import com.xly.service.FormResolverService; -import dev.langchain4j.agent.tool.P; -import dev.langchain4j.agent.tool.Tool; -import dev.langchain4j.model.chat.ChatModel; -import net.sf.jsqlparser.expression.StringValue; -import net.sf.jsqlparser.expression.operators.conditional.AndExpression; -import net.sf.jsqlparser.expression.operators.relational.EqualsTo; -import net.sf.jsqlparser.parser.CCJSqlParserUtil; -import net.sf.jsqlparser.schema.Column; -import net.sf.jsqlparser.schema.Table; -import net.sf.jsqlparser.statement.Statement; -import net.sf.jsqlparser.statement.select.PlainSelect; -import net.sf.jsqlparser.statement.select.Select; -import net.sf.jsqlparser.util.TablesNamesFinder; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Pattern; - -/** - * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 - * (跨表汇总、按条件计数排名等)。 - * - *

安全栈(架构 §9):用 coder 模型据 KG 字段字典接地生成 SQL → jsqlparser 强制**单条 SELECT** → - * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → - * **表白名单**({@link #allowedTables()}:viw_* + 表单数据源 + 字段字典表,挡掉 gdslogininfo / - * sysjurisdiction / ai_op_queue 等敏感表) → **AST 注入租户谓词**({@link #applyTenant},单表查询按 - * {@code sBrandsId}) → 强制 LIMIT。SQL 全部入审计。 - * - *

尚未落地的加固项:多表 JOIN 只靠 prompt 提示带租户过滤(AST 注入只覆盖单表)、 - * 以及独立的只读 MySQL 账号(当前与应用共用连接池)。 - */ -public class QueryTool { - - private final ChatModel sqlModel; - private final JdbcTemplate jdbc; - private final AuditService audit; - private final AgentIdentity identity; - private final FormResolverService resolver; - - public QueryTool(ChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, - AgentIdentity identity, FormResolverService resolver) { - this.sqlModel = sqlModel; - this.jdbc = jdbc; - this.audit = audit; - this.identity = identity; - this.resolver = resolver; - } - - @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" - + "(如跨表汇总、按条件计数、排名、分组统计)。仅在 readFormData / lookupRecord 无法回答时才用。") - public String queryData(@P("用自然语言描述要统计/分析什么") String question) { - if (question == null || question.isBlank()) { - return "请描述要查询统计的内容。"; - } - String hint = schemaHint(question); - String sql = null; - String lastErr = null; - // 自修复重试:SQL 校验/执行报错就把错误喂回模型重新生成,最多 3 次 - for (int attempt = 0; attempt < 3; attempt++) { - try { - sql = cleanSql(sqlModel.chat(buildPrompt(hint, question, sql, lastErr))); - } catch (Exception e) { - return "生成查询失败:" + e.getMessage(); - } - String reject = validate(sql); - if (reject != null) { - lastErr = reject; - if (attempt < 2) { - continue; - } - audit.log(null, null, "query", "REJECTED", sql, false, reject); - return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; - } - String limited = forceLimit(applyTenant(sql)); - try { - List> rows = jdbc.queryForList(limited); - audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); - return formatRows(rows); - } catch (Exception e) { - lastErr = rootMsg(e); - if (attempt < 2) { - continue; // 下一轮把错误喂回模型自修复 - } - audit.log(null, null, "query", "fail", limited, false, lastErr); - return "查询执行失败(已尝试自修复):" + lastErr; - } - } - return "查询失败。"; - } - - private String buildPrompt(String hint, String question, String prevSql, String prevErr) { - String repair = (prevSql == null || prevErr == null) ? "" : - "\n\n上一条 SQL:\n" + prevSql + "\n执行/校验报错:" + prevErr + - "\n请**修正该错误**后重新生成一条正确的 SELECT(注意用对表名列名、别用中文别名)。"; - return """ - 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 - 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): - %s - 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表; - 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据); - **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; - **给用户看的是名称不是内部ID**:若按某实体(产品/客户/物料/供应商)分组或排名,必须 JOIN 该实体主表、 - 在结果里返回它的**名称列**(如 eleproduct.sProductName、elecustomer.sCustomerName),不要只返回 *Id 列; - 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 - 问题:%s%s - """.formatted(hint, brandHint(), question, repair); - } - - private String brandHint() { - String b = identity == null ? null : identity.brandsId(); - return (b == null || b.isBlank()) ? "本企业" : b; - } - - private String rootMsg(Throwable e) { - Throwable r = e; - while (r.getCause() != null && r.getCause() != r) { - r = r.getCause(); - } - String m = r.getMessage(); - return m == null ? e.toString() : (m.length() > 300 ? m.substring(0, 300) : m); - } - - /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ - private String schemaHint(String question) { - StringBuilder sb = new StringBuilder(); - try { - List> rows = jdbc.queryForList( - "SELECT fd.sTable, " + - "GROUP_CONCAT(DISTINCT CONCAT(fd.sField,'=',fd.sChinese) ORDER BY fd.iFormUses DESC SEPARATOR ', ') cols, " + - "SUM(fd.iFormUses) usage_ " + - "FROM viw_kg_field_dict fd " + - "WHERE CHAR_LENGTH(fd.sChinese)>=2 AND INSTR(?, fd.sChinese)>0 " + - "AND fd.sTable NOT LIKE 'viw%' " + - "AND fd.sTable IN (SELECT DISTINCT sDataSource FROM viw_ai_useful_forms) " + - "GROUP BY fd.sTable ORDER BY usage_ DESC, COUNT(*) DESC LIMIT 6", question); - for (Map r : rows) { - String cols = String.valueOf(r.get("cols")); - if (cols.length() > 400) { - cols = cols.substring(0, 400) + "…"; - } - sb.append("- ").append(r.get("sTable")).append("(").append(cols).append(")\n"); - } - } catch (Exception ignore) { - } - if (sb.length() == 0) { - sb.append("(未匹配到具体表;请在问题里使用业务术语,如 客户 / 订单 / 金额 / 数量)\n"); - } - return sb.toString(); - } - - private String cleanSql(String raw) { - if (raw == null) { - return ""; - } - String s = raw.replace("```sql", "").replace("```", "").trim(); - int i = s.toLowerCase().indexOf("select"); - if (i > 0) { - s = s.substring(i); - } - int semi = s.indexOf(';'); - if (semi >= 0) { - s = s.substring(0, semi); - } - return s.trim(); - } - - // AI 可用业务表/视图白名单(静态元数据,跨请求缓存);含 sBrandsId 列的表缓存。 - private static volatile Set ALLOWED_TABLES; - private static final ConcurrentHashMap BRAND_COL = new ConcurrentHashMap<>(); - - /** - * 单条 SELECT + 挡危险构造 + **表白名单**(架构 §9 的“视图白名单”)。 - * 白名单 = AI 可用的业务表/视图(viw_* + 表单数据源 + 字段字典表),把凭证/权限/暂存等敏感表挡在外面 - * (如 gdslogininfo、sysjurisdiction、ai_op_queue),防 NL2SQL 击穿权限。返回 null=通过,否则=拒绝原因。 - */ - private String validate(String sql) { - if (sql == null || sql.isBlank()) { - return "未生成SQL"; - } - String low = sql.toLowerCase(); - String[] bad = {"into outfile", "into dumpfile", "load_file", "load data", - "information_schema", "sleep(", "benchmark(", "sys.", "mysql."}; - for (String b : bad) { - if (low.contains(b)) { - return "含禁止构造: " + b; - } - } - Statement stmt; - try { - stmt = CCJSqlParserUtil.parse(sql); - } catch (Exception e) { - return "SQL 解析失败"; - } - if (!(stmt instanceof Select)) { - return "只允许 SELECT"; - } - Set allowed = allowedTables(); - if (!allowed.isEmpty()) { - List tables; - try { - tables = new TablesNamesFinder().getTableList(stmt); - } catch (Exception e) { - return "无法解析查询涉及的表"; - } - for (String t : tables) { - if (!allowed.contains(normTable(t))) { - return "涉及不允许访问的表(" + normTable(t) + ")"; - } - } - } - return null; - } - - /** AI 可用表/视图集合(小写):所有 viw_* 视图 + 表单数据源 + 字段字典里出现的基础表。静态缓存。 */ - private Set allowedTables() { - Set c = ALLOWED_TABLES; - if (c != null) { - return c; - } - Set s = new HashSet<>(); - try { - for (Map r : jdbc.queryForList( - "SELECT LOWER(TABLE_NAME) t FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'viw\\_%'")) { - s.add(String.valueOf(r.get("t"))); - } - for (Map r : jdbc.queryForList( - "SELECT DISTINCT LOWER(sDataSource) t FROM viw_ai_useful_forms WHERE IFNULL(sDataSource,'')<>''")) { - s.add(String.valueOf(r.get("t"))); - } - for (Map r : jdbc.queryForList( - "SELECT DISTINCT LOWER(sTable) t FROM viw_kg_field_dict WHERE IFNULL(sTable,'')<>''")) { - s.add(String.valueOf(r.get("t"))); - } - } catch (Exception ignore) { - // 元数据不可用时返回空集 → 不启用白名单(保持可用),但仍有 SELECT-only + 禁止构造兜底。 - } - ALLOWED_TABLES = s; - return s; - } - - /** 规整表名:去反引号、去 schema 前缀、转小写。 */ - private static String normTable(String t) { - if (t == null) { - return ""; - } - String x = t.replace("`", "").trim(); - int dot = x.lastIndexOf('.'); - if (dot >= 0) { - x = x.substring(dot + 1); - } - return x.toLowerCase(); - } - - /** - * 租户注入(架构 §9):单表(无 JOIN)且该表含 sBrandsId 列、且身份带品牌时,追加 - * {@code AND sBrandsId=''},把结果限定在本企业。视图(viw_*)通常已按品牌预筛,跳过。 - * 任何异常都退回原 SQL(不因注入失败而阻断,白名单已是主要边界)。 - */ - private String applyTenant(String sql) { - String brand = identity == null ? null : identity.brandsId(); - if (brand == null || brand.isBlank()) { - return sql; - } - try { - Statement stmt = CCJSqlParserUtil.parse(sql); - if (!(stmt instanceof Select)) { - return sql; - } - Select select = (Select) stmt; - if (!(select.getSelectBody() instanceof PlainSelect)) { - return sql; - } - PlainSelect ps = (PlainSelect) select.getSelectBody(); - if (ps.getJoins() != null && !ps.getJoins().isEmpty()) { - return sql; // 多表:交给白名单,不做注入 - } - if (!(ps.getFromItem() instanceof Table)) { - return sql; - } - String table = normTable(((Table) ps.getFromItem()).getName()); - if (table.startsWith("viw")) { - return sql; // 视图预筛,不注入 - } - if (!hasBrandCol(table)) { - return sql; - } - EqualsTo eq = new EqualsTo(); - eq.setLeftExpression(new Column("sBrandsId")); - eq.setRightExpression(new StringValue(brand)); - ps.setWhere(ps.getWhere() == null ? eq : new AndExpression(ps.getWhere(), eq)); - return select.toString(); - } catch (Exception e) { - return sql; - } - } - - /** 该基础表是否有 sBrandsId 列(缓存)。 */ - private boolean hasBrandCol(String table) { - return BRAND_COL.computeIfAbsent(table, t -> { - try { - Integer n = jdbc.queryForObject( - "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + - "AND TABLE_NAME=? AND COLUMN_NAME='sBrandsId'", Integer.class, t); - return n != null && n > 0; - } catch (Exception e) { - return false; - } - }); - } - - private String forceLimit(String sql) { - String low = sql.toLowerCase(); - if (!low.matches("(?s).*\\blimit\\b.*")) { - return sql.trim() + " LIMIT 100"; - } - return sql; - } - - // 裸主键ID(业务 sId 通常为 15+ 位数字串)。仅对严格匹配的长数字尝试解析,普通数字(数量/金额)不受影响。 - private static final Pattern LONG_ID = Pattern.compile("\\d{15,}"); - private static volatile Map NAME_TABLES; // 主表 -> 名称字段(静态缓存) - private final Map idNameCache = new ConcurrentHashMap<>(); // 本次请求内的 id->名称 缓存 - - /** 被外键引用的主表集合及其名称字段(客户/产品/物料/供应商…)。用于把裸ID回解析成名称。 */ - private Map nameTables() { - Map c = NAME_TABLES; - if (c != null) { - return c; - } - Map m = new LinkedHashMap<>(); - try { - for (Map r : jdbc.queryForList( - "SELECT DISTINCT sFkTable FROM viw_kg_field_dict WHERE IFNULL(sFkTable,'')<>'' " + - "AND sFkTable NOT LIKE 'viw%'")) { - String t = String.valueOf(r.get("sFkTable")); - if (t == null || t.isBlank()) continue; - String nf = resolver.resolveNameField(t); - if (nf != null && !nf.isBlank()) { - m.put(t, nf); - } - } - } catch (Exception ignore) { - } - NAME_TABLES = m; - return m; - } - - /** 长ID → 名称(在被引用主表里按 sId 命中即返回;找不到返回 null,保留原ID)。 */ - private String resolveId(String val) { - String cached = idNameCache.get(val); - if (cached != null) { - return cached.isEmpty() ? null : cached; - } - for (Map.Entry e : nameTables().entrySet()) { - try { - List> r = jdbc.queryForList( - "SELECT `" + e.getValue() + "` nm FROM `" + e.getKey() + "` WHERE sId=? LIMIT 1", val); - if (!r.isEmpty()) { - Object nm = r.get(0).get("nm"); - if (nm != null && !String.valueOf(nm).isBlank()) { - String s = String.valueOf(nm); - idNameCache.put(val, s); - return s; - } - } - } catch (Exception ignore) { - } - } - idNameCache.put(val, ""); - return null; - } - - private String formatRows(List> rows) { - if (rows.isEmpty()) { - return "查询完成,没有匹配的数据。"; - } - List cols = List.copyOf(rows.get(0).keySet()); - StringBuilder sb = new StringBuilder(); - sb.append("查询结果(").append(rows.size()).append(" 行):\n\n"); - sb.append("| ").append(String.join(" | ", cols)).append(" |\n"); - sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); - int shown = 0; - for (Map r : rows) { - if (shown++ >= 30) { - sb.append("| … 仅显示前 30 行 |").append(" |".repeat(Math.max(0, cols.size() - 1))).append("\n"); - break; - } - StringBuilder line = new StringBuilder("| "); - for (String c : cols) { - Object v = r.get(c); - String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); - // 把裸的长ID(如产品/客户 sId)就地解析成名称,给用户看名字不是ID - if (LONG_ID.matcher(s).matches()) { - String nm = resolveId(s); - if (nm != null) s = nm; - } - if (s.length() > 30) { - s = s.substring(0, 30) + "…"; - } - line.append(s).append(" | "); - } - sb.append(line).append("\n"); - } - return sb.toString(); - } -} diff --git a/src/main/java/com/xly/tool/SkillTool.java b/src/main/java/com/xly/tool/SkillTool.java deleted file mode 100644 index 745dd09..0000000 --- a/src/main/java/com/xly/tool/SkillTool.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.xly.tool; - -import com.xly.service.SkillService; -import dev.langchain4j.agent.tool.P; -import dev.langchain4j.agent.tool.Tool; -import org.springframework.stereotype.Component; - -import java.util.Map; - -/** - * Skill 加载工具(架构 §6,渐进披露)。 - * - *

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

新编排(架构 §5 意图门 + 确定性路由):不再让一个弱模型在全部 9 个工具里一次性盲选。 - * 每轮先用 {@link IntentService} 做**受约束 JSON** 的意图+实体分类,再确定性路由: + *

编排(架构 §5 意图门 + 确定性路由):每轮先用 {@link IntentService} 做**受约束 JSON** + * 的 4 类意图+实体分类,再确定性路由: *

- * 收窄工具集 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 + * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 * *

帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 @@ -119,12 +117,12 @@ public class AgentChatController { /** 意图门 + 确定性路由。 */ private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { - // 0) 表单提交 → 直接走写(执行 proposeWrite(action=create)),不再重新分类。 + // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 if (userInput.contains(FORM_SUBMIT_MARK)) { - runAgent(emitter, convId, identity, ToolScope.WRITE, userInput); + runAgent(emitter, convId, identity, userInput); return; } - // 1) 意图门(失败时返回 UNCLEAR,走兜底)。 + // 1) 意图门(失败时返回 其他,走兜底)。 Intent it = intentService.classify(userInput); log.info("intent(conv={}): {} / {} / entities={} / missing={}", convId, it.intent, it.danju, it.describeEntities(), it.missing); @@ -134,24 +132,18 @@ public class AgentChatController { if (handleCreate(emitter, convId, identity, userInput, it)) { return; } - // 无法确定性建表单 → 交给写 agent 处理(可能需要它先问清单据类型)。 - runAgent(emitter, convId, identity, ToolScope.WRITE, ground(userInput, it)); + // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 + runAgent(emitter, convId, identity, ground(userInput, it)); return; - case Intent.UPDATE: - case Intent.DELETE: - case Intent.EXAMINE: + case Intent.OPERATE: handleWrite(emitter, convId, identity, userInput, it); return; case Intent.QUERY: - runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it)); + runAgent(emitter, convId, identity, ground(userInput, it)); return; - case Intent.CHAT: - runAgent(emitter, convId, identity, ToolScope.READ, userInput); - return; - case Intent.UNCLEAR: default: - // 分类不确定/失败:给全部工具、原文交给 agent,尽量不丢能力。 - runAgent(emitter, convId, identity, ToolScope.FULL, userInput); + // 其他/分类失败:原文交给 agent,尽量不丢能力。 + runAgent(emitter, convId, identity, userInput); } } @@ -199,11 +191,10 @@ public class AgentChatController { return false; } - /** 运行某一范围的 ReAct agent,把流式回调转成 SSE。 */ - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, - ToolScope scope, String text) { + /** 运行 ReAct agent,把流式回调转成 SSE。 */ + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text) { try { - ReActAgent agent = agentFactory.build(identity, scope); + ReActAgent agent = agentFactory.build(identity); TokenStream ts = agent.chat(convId, text); ts.onPartialResponse(token -> send(emitter, "token", token)) .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) @@ -246,8 +237,8 @@ public class AgentChatController { } /** - * 写操作(修改/删除/审核)处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), - * 齐了就带干净 grounding 交给写 agent 直接 propose*(它会自定位主表/记录), + * 「操作已有单据」处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), + * 齐了就确定性调 proposeWrite(它会自定位主表/记录), * 缺了就**确定性问一次**并停下——两头都不进失控循环。 */ private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, @@ -256,7 +247,7 @@ public class AgentChatController { log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", convId, w.entityType, w.record, w.field, w.newValue); - String action = deriveWriteAction(it.intent, userInput); + String action = deriveWriteAction(userInput); String ent = pickEntity(w.entityType, it.danju); // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) @@ -312,16 +303,15 @@ public class AgentChatController { return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); } - /** 由意图 + 用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删)。 */ - private String deriveWriteAction(String intent, String text) { + /** 从用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删;「删除」默认=作废,安全可复原)。 */ + private String deriveWriteAction(String text) { String t = text == null ? "" : text; if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; if (t.contains("作废")) return "invalid"; if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; - if (Intent.UPDATE.equals(intent)) return "update"; - if (Intent.EXAMINE.equals(intent)) return "examine"; - if (Intent.DELETE.equals(intent)) return "invalid"; // 业务单据"删除"默认=作废(安全、可复原) + if (t.contains("审核") || t.contains("审批")) return "examine"; + if (t.contains("删除") || t.contains("删掉") || t.contains("删了")) return "invalid"; return "update"; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c0096f2..324abfa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,8 +66,6 @@ llm: base-url: http://112.82.245.194:41434/v1 api-key: ollama # Ollama 不校验(任意非空);云端填真实 key chat-model: qwen3.6-27b-iq3:latest - # SQL/代码模型(QueryTool NL2SQL 专用) - sql-model: qwen3.6-27b-iq3:latest erp: baseurl: http://118.178.19.35:8080/xlyEntry_saas