Commit 8b927e3bba7ff627a623e6706c758ee2c930b905
1 parent
ac642a35
feat(agent): intent gate + tool scoping to cut wrong-tool selection
Add a deterministic §5 intent stage before ReAct: extract {intent, form,
entities, missing slots} via constrained JSON, then expose only the 3-5
tools relevant to that intent instead of all 12.
New:
- agent/Intent, agent/ToolScope: intent + visible-tool-set model
- service/IntentService: constrained-JSON intent/entity extraction
- service/OllamaJsonClient: JSON-mode Ollama calls
- service/SlotFillService: slot extraction/prefill of known values
Wire through AgentFactory, SystemPromptService, QueryTool,
ProposeWriteTool, ErpClient, AgentChatController, OpController, chat.html.
Showing
13 changed files
with
1144 additions
and
131 deletions
src/main/java/com/xly/agent/Intent.java
0 → 100644
| 1 | +package com.xly.agent; | |
| 2 | + | |
| 3 | +import java.util.ArrayList; | |
| 4 | +import java.util.List; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 | |
| 8 | + * | |
| 9 | + * <p>由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 | |
| 10 | + * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**, | |
| 11 | + * 而不是让弱模型在 12 个工具里盲选——这是治理「理解错/选错工具」的核心。 | |
| 12 | + */ | |
| 13 | +public class Intent { | |
| 14 | + | |
| 15 | + /** 操作意图。 */ | |
| 16 | + public static final String QUERY = "查询"; | |
| 17 | + 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 = "不清楚"; | |
| 23 | + | |
| 24 | + public static class Entity { | |
| 25 | + public String value; | |
| 26 | + public String role; // 客户/产品/物料/供应商/数量/尺寸/日期/金额/其他/未知 | |
| 27 | + public Entity() {} | |
| 28 | + public Entity(String value, String role) { this.value = value; this.role = role; } | |
| 29 | + } | |
| 30 | + | |
| 31 | + /** 写操作(修改/删除/审核)的专用槽位:比通用 entities 更贴合 propose* 工具的入参。 */ | |
| 32 | + public static class WriteSlots { | |
| 33 | + public String entityType = ""; // 记录所属实体:客户/供应商/物料/产品/单据… | |
| 34 | + public String record = ""; // 要操作的记录名(公司名/物料名/单号) | |
| 35 | + public String field = ""; // 要修改的字段中文名(删除/审核留空) | |
| 36 | + public String newValue = ""; // 修改后的新值(删除/审核留空) | |
| 37 | + } | |
| 38 | + | |
| 39 | + public String intent = UNCLEAR; | |
| 40 | + public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 | |
| 41 | + public List<Entity> entities = new ArrayList<>(); | |
| 42 | + public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息 | |
| 43 | + | |
| 44 | + public boolean isWrite() { | |
| 45 | + return CREATE.equals(intent) || UPDATE.equals(intent) | |
| 46 | + || DELETE.equals(intent) || EXAMINE.equals(intent); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** 取某业务角色的第一个实体值(如「客户」「产品」),没有则返回 null。 */ | |
| 50 | + public String firstValueByRole(String role) { | |
| 51 | + for (Entity e : entities) { | |
| 52 | + if (e != null && role.equals(e.role) && e.value != null && !e.value.isBlank()) { | |
| 53 | + return e.value.trim(); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + return null; | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** 所有实体值拼接(用于给下游 agent 做 grounding 描述)。 */ | |
| 60 | + public String describeEntities() { | |
| 61 | + StringBuilder sb = new StringBuilder(); | |
| 62 | + for (Entity e : entities) { | |
| 63 | + if (e == null || e.value == null || e.value.isBlank()) continue; | |
| 64 | + if (sb.length() > 0) sb.append(","); | |
| 65 | + sb.append(e.value).append("(").append(e.role == null ? "未知" : e.role).append(")"); | |
| 66 | + } | |
| 67 | + return sb.toString(); | |
| 68 | + } | |
| 69 | +} | ... | ... |
src/main/java/com/xly/agent/ToolScope.java
0 → 100644
| 1 | +package com.xly.agent; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 按意图收窄「本轮可见的工具集」(架构 §5 的 tool scoping)。 | |
| 5 | + * | |
| 6 | + * <p>依据:单次 ReAct 的选工具准确率随工具数量上升而骤降(业界经验:50 个工具 ~94% → 200 个 ~64%)。 | |
| 7 | + * 弱模型(qwen3:14b Q4)一次性面对 12 个语义重叠的工具时,频繁选错族(把「查询」当「新增」)。 | |
| 8 | + * 于是先用意图门判类,再**只暴露该意图相关的 3-5 个工具**给 ReAct 执行——大幅降低选错概率。 | |
| 9 | + */ | |
| 10 | +public enum ToolScope { | |
| 11 | + /** 读:findForms/kgSearch/readFormData/lookupRecord/queryData(+askUser/loadSkill)。 */ | |
| 12 | + READ, | |
| 13 | + /** 写:collectForm/proposeCreate/Update/Delete/Examine(+lookupRecord 定位、askUser/loadSkill)。 */ | |
| 14 | + WRITE, | |
| 15 | + /** 兜底:全部工具(意图门失败时用)。 */ | |
| 16 | + FULL | |
| 17 | +} | ... | ... |
src/main/java/com/xly/config/AgentFactory.java
| ... | ... | @@ -3,6 +3,7 @@ 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; | |
| 6 | 7 | import com.xly.service.AuditService; |
| 7 | 8 | import com.xly.service.ErpClient; |
| 8 | 9 | import com.xly.service.FormResolverService; |
| ... | ... | @@ -54,8 +55,6 @@ public class AgentFactory { |
| 54 | 55 | private final SkillTool skillTool; |
| 55 | 56 | private final InteractionTool interactionTool; |
| 56 | 57 | |
| 57 | - private volatile String cachedSystemPrompt; | |
| 58 | - | |
| 59 | 58 | public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel, |
| 60 | 59 | @Qualifier("sqlChatModel") OllamaChatModel sqlModel, |
| 61 | 60 | RedisChatMemoryStore memoryStore, |
| ... | ... | @@ -79,33 +78,43 @@ public class AgentFactory { |
| 79 | 78 | this.interactionTool = interactionTool; |
| 80 | 79 | } |
| 81 | 80 | |
| 82 | - /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */ | |
| 83 | - private String systemPrompt() { | |
| 84 | - String p = cachedSystemPrompt; | |
| 85 | - if (p == null) { | |
| 86 | - synchronized (this) { | |
| 87 | - if (cachedSystemPrompt == null) { | |
| 88 | - cachedSystemPrompt = systemPromptService.buildSystemPrompt(); | |
| 89 | - } | |
| 90 | - p = cachedSystemPrompt; | |
| 91 | - } | |
| 92 | - } | |
| 93 | - return p; | |
| 81 | + /** 兜底:全部工具(意图门失败时用)。 */ | |
| 82 | + public ReActAgent build(AgentIdentity identity) { | |
| 83 | + return build(identity, ToolScope.FULL); | |
| 94 | 84 | } |
| 95 | 85 | |
| 96 | - /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */ | |
| 97 | - public ReActAgent build(AgentIdentity identity) { | |
| 98 | - String systemPrompt = systemPrompt(); | |
| 86 | + /** | |
| 87 | + * 按意图范围组装 ReAct agent:只暴露该范围相关的工具 + 对应版本的 system prompt。 | |
| 88 | + * | |
| 89 | + * <p>收窄可见工具(3-5 个)显著提升弱模型的选工具准确率;{@code maxSequentialToolsInvocations} | |
| 90 | + * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 | |
| 91 | + */ | |
| 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(erp, 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(erp, jdbc, resolver, identity, mapper)}; | |
| 112 | + } | |
| 113 | + | |
| 99 | 114 | return AiServices.builder(ReActAgent.class) |
| 100 | 115 | .streamingChatModel(streamingModel) |
| 101 | - .tools( | |
| 102 | - kgQueryTool, | |
| 103 | - skillTool, | |
| 104 | - interactionTool, | |
| 105 | - new ErpReadTool(erp, jdbc, resolver, identity), | |
| 106 | - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), | |
| 107 | - new QueryTool(sqlModel, jdbc, audit, identity), | |
| 108 | - new FormCollectTool(erp, jdbc, resolver, identity, mapper)) | |
| 116 | + .tools(tools) | |
| 117 | + .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 | |
| 109 | 118 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() |
| 110 | 119 | .id(memoryId) |
| 111 | 120 | .maxMessages(30) |
| ... | ... | @@ -114,4 +123,14 @@ public class AgentFactory { |
| 114 | 123 | .systemMessageProvider(memoryId -> systemPrompt) |
| 115 | 124 | .build(); |
| 116 | 125 | } |
| 126 | + | |
| 127 | + /** 供确定性「新增」路径直接构建 collectForm 表单(不经 LLM 工具选择)。 */ | |
| 128 | + public FormCollectTool formCollectTool(AgentIdentity identity) { | |
| 129 | + return new FormCollectTool(erp, jdbc, resolver, identity, mapper); | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** 供确定性「修改/作废/审核…」路径直接调 proposeWrite(不经 LLM 工具选择)。 */ | |
| 133 | + public ProposeWriteTool proposeWriteTool(AgentIdentity identity) { | |
| 134 | + return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver); | |
| 135 | + } | |
| 117 | 136 | } | ... | ... |
src/main/java/com/xly/service/ErpClient.java
| ... | ... | @@ -194,6 +194,45 @@ public class ErpClient { |
| 194 | 194 | } |
| 195 | 195 | |
| 196 | 196 | /** |
| 197 | + * 作废 / 取消作废(复原)一条记录(ERP {@code /checkModel/updatebInvalid} → 存储过程 {@code Sp_Invalidation})。 | |
| 198 | + * {@code cancel=false} → 作废(handleType=toVoid);{@code cancel=true} → 取消作废/复原(handleType=cancel)。 | |
| 199 | + * 业务单据的“删除”应走这里(软作废,可复原),而非物理删。会话过期自动重登重试。 | |
| 200 | + */ | |
| 201 | + public JsonNode invalidForm(String authToken, String moduleId, String table, String billId, boolean cancel) { | |
| 202 | + JsonNode root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); | |
| 203 | + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { | |
| 204 | + login(); | |
| 205 | + root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); | |
| 206 | + } | |
| 207 | + return root; | |
| 208 | + } | |
| 209 | + | |
| 210 | + private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) { | |
| 211 | + try { | |
| 212 | + String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + moduleId; | |
| 213 | + Map<String, Object> body = new LinkedHashMap<>(); | |
| 214 | + body.put("sId", List.of(billId)); // 后端 sId 期望 List<String> | |
| 215 | + body.put("sTableName", table); | |
| 216 | + body.put("handleType", cancel ? "cancel" : "toVoid"); | |
| 217 | + body.put("sClientType", "PC"); | |
| 218 | + body.put("sComputeName", "AI-Agent"); | |
| 219 | + body.put("sIpAddress", "127.0.0.1"); | |
| 220 | + body.put("sLanguage", "chinese"); | |
| 221 | + String json = mapper.writeValueAsString(body); | |
| 222 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | |
| 223 | + .header("Content-Type", "application/json;charset=UTF-8") | |
| 224 | + .header("Authorization", tok) | |
| 225 | + .timeout(Duration.ofSeconds(60)) | |
| 226 | + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) | |
| 227 | + .build(); | |
| 228 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 229 | + return mapper.readTree(resp.body()); | |
| 230 | + } catch (Exception e) { | |
| 231 | + throw new RuntimeException("ERP 作废异常: " + e.getMessage(), e); | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + /** | |
| 197 | 236 | * 委托 ERP 侧暂存执行器执行一条暂存写操作(架构 §10)。ERP 读共享库 {@code ai_op_queue} 行、以用户身份 |
| 198 | 237 | * 执行并回写状态,返回 {@code {status, msg, billId}}。用户 token 必传(执行以用户身份进行)。 |
| 199 | 238 | */ | ... | ... |
src/main/java/com/xly/service/IntentService.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.xly.agent.Intent; | |
| 5 | +import org.slf4j.Logger; | |
| 6 | +import org.slf4j.LoggerFactory; | |
| 7 | +import org.springframework.stereotype.Service; | |
| 8 | + | |
| 9 | +import java.util.LinkedHashMap; | |
| 10 | +import java.util.List; | |
| 11 | +import java.util.Map; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 | |
| 15 | + * | |
| 16 | + * <p>只做**一件窄任务**,用受约束 JSON 解码({@link OllamaJsonClient})。实测在此形态下 | |
| 17 | + * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」 | |
| 18 | + * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在 12 个工具的 | |
| 19 | + * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。 | |
| 20 | + * | |
| 21 | + * <p>失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。 | |
| 22 | + */ | |
| 23 | +@Service | |
| 24 | +public class IntentService { | |
| 25 | + | |
| 26 | + private static final Logger log = LoggerFactory.getLogger(IntentService.class); | |
| 27 | + | |
| 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,不要解释。"; | |
| 41 | + | |
| 42 | + private final OllamaJsonClient llm; | |
| 43 | + | |
| 44 | + public IntentService(OllamaJsonClient llm) { | |
| 45 | + this.llm = llm; | |
| 46 | + } | |
| 47 | + | |
| 48 | + /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */ | |
| 49 | + public Intent classify(String utterance) { | |
| 50 | + Intent out = new Intent(); | |
| 51 | + if (utterance == null || utterance.isBlank()) { | |
| 52 | + return out; | |
| 53 | + } | |
| 54 | + JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); | |
| 55 | + if (n == null) { | |
| 56 | + log.warn("intent classify fell back to UNCLEAR (model unavailable)"); | |
| 57 | + return out; | |
| 58 | + } | |
| 59 | + String intent = n.path("intent").asText(Intent.UNCLEAR); | |
| 60 | + out.intent = normalizeIntent(intent); | |
| 61 | + out.danju = n.path("danju").asText(""); | |
| 62 | + JsonNode es = n.path("entities"); | |
| 63 | + if (es.isArray()) { | |
| 64 | + for (JsonNode e : es) { | |
| 65 | + String v = e.path("value").asText(""); | |
| 66 | + String r = e.path("role").asText("未知"); | |
| 67 | + if (!v.isBlank()) { | |
| 68 | + out.entities.add(new Intent.Entity(v, r)); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | + JsonNode ms = n.path("missing"); | |
| 73 | + if (ms.isArray()) { | |
| 74 | + for (JsonNode m : ms) { | |
| 75 | + String v = m.asText(""); | |
| 76 | + if (!v.isBlank()) out.missing.add(v); | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return out; | |
| 80 | + } | |
| 81 | + | |
| 82 | + private static final String WRITE_SYSTEM = | |
| 83 | + "你是 ERP 写操作的槽位抽取器。从用户话中抽取要操作的记录及改动:" | |
| 84 | + + "record=要操作的那条记录的名称(公司名/客户名/物料名/单号等);" | |
| 85 | + + "entityType=该记录属于哪类实体(客户/供应商/物料/产品/单据;拿不准就填 客户);" | |
| 86 | + + "field=要修改的字段中文名(如 电话/地址/简称/备注),删除或审核时留空;" | |
| 87 | + + "newValue=改成什么新值,删除或审核时留空。只输出 JSON,不要解释。"; | |
| 88 | + | |
| 89 | + /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ | |
| 90 | + public Intent.WriteSlots extractWrite(String utterance) { | |
| 91 | + Intent.WriteSlots w = new Intent.WriteSlots(); | |
| 92 | + if (utterance == null || utterance.isBlank()) { | |
| 93 | + return w; | |
| 94 | + } | |
| 95 | + JsonNode n = llm.completeJson(WRITE_SYSTEM, utterance.trim(), writeSchema()); | |
| 96 | + if (n == null) { | |
| 97 | + return w; | |
| 98 | + } | |
| 99 | + w.entityType = n.path("entityType").asText(""); | |
| 100 | + w.record = n.path("record").asText(""); | |
| 101 | + w.field = n.path("field").asText(""); | |
| 102 | + w.newValue = n.path("newValue").asText(""); | |
| 103 | + return w; | |
| 104 | + } | |
| 105 | + | |
| 106 | + private Map<String, Object> writeSchema() { | |
| 107 | + Map<String, Object> props = new LinkedHashMap<>(); | |
| 108 | + props.put("entityType", Map.of("type", "string")); | |
| 109 | + props.put("record", Map.of("type", "string")); | |
| 110 | + props.put("field", Map.of("type", "string")); | |
| 111 | + props.put("newValue", Map.of("type", "string")); | |
| 112 | + Map<String, Object> schema = new LinkedHashMap<>(); | |
| 113 | + schema.put("type", "object"); | |
| 114 | + schema.put("properties", props); | |
| 115 | + schema.put("required", List.of("record")); | |
| 116 | + return schema; | |
| 117 | + } | |
| 118 | + | |
| 119 | + private static String normalizeIntent(String s) { | |
| 120 | + if (s == null) return Intent.UNCLEAR; | |
| 121 | + s = s.trim(); | |
| 122 | + switch (s) { | |
| 123 | + case Intent.QUERY: | |
| 124 | + case Intent.CREATE: | |
| 125 | + case Intent.UPDATE: | |
| 126 | + case Intent.DELETE: | |
| 127 | + case Intent.EXAMINE: | |
| 128 | + case Intent.CHAT: | |
| 129 | + case Intent.UNCLEAR: | |
| 130 | + return s; | |
| 131 | + default: | |
| 132 | + return Intent.UNCLEAR; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + private Map<String, Object> schema() { | |
| 137 | + Map<String, Object> entityProps = new LinkedHashMap<>(); | |
| 138 | + entityProps.put("value", Map.of("type", "string")); | |
| 139 | + entityProps.put("role", Map.of("type", "string", | |
| 140 | + "enum", List.of("客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知"))); | |
| 141 | + Map<String, Object> entityItem = new LinkedHashMap<>(); | |
| 142 | + entityItem.put("type", "object"); | |
| 143 | + entityItem.put("properties", entityProps); | |
| 144 | + entityItem.put("required", List.of("value", "role")); | |
| 145 | + | |
| 146 | + Map<String, Object> props = new LinkedHashMap<>(); | |
| 147 | + props.put("intent", Map.of("type", "string", | |
| 148 | + "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚"))); | |
| 149 | + props.put("danju", Map.of("type", "string")); | |
| 150 | + props.put("entities", Map.of("type", "array", "items", entityItem)); | |
| 151 | + props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); | |
| 152 | + | |
| 153 | + Map<String, Object> schema = new LinkedHashMap<>(); | |
| 154 | + schema.put("type", "object"); | |
| 155 | + schema.put("properties", props); | |
| 156 | + schema.put("required", List.of("intent", "danju", "entities")); | |
| 157 | + return schema; | |
| 158 | + } | |
| 159 | +} | ... | ... |
src/main/java/com/xly/service/OllamaJsonClient.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import com.xly.util.OkHttpUtil; | |
| 6 | +import org.slf4j.Logger; | |
| 7 | +import org.slf4j.LoggerFactory; | |
| 8 | +import org.springframework.beans.factory.annotation.Value; | |
| 9 | +import org.springframework.stereotype.Service; | |
| 10 | + | |
| 11 | +import java.util.LinkedHashMap; | |
| 12 | +import java.util.List; | |
| 13 | +import java.util.Map; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 直连 Ollama /api/chat 的**受约束 JSON**补全(constrained decoding)。 | |
| 17 | + * | |
| 18 | + * <p>为什么绕开 LangChain4j:Ollama 的 {@code format=<JSON Schema>} 会用 XGrammar 做语法约束解码, | |
| 19 | + * 保证输出**一定**是 schema 合法的 JSON(违反 schema 的 token 概率直接置 0)——这是根治「把产品名塞进客户字段」 | |
| 20 | + * 这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下意图/实体抽取 8/8 正确、~2-3s/次(think=false)。 | |
| 21 | + * | |
| 22 | + * <p>用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与 | |
| 23 | + * {@link SlotFillService}(按表单真实字段做受约束的槽位填充)。主对话/查询仍走 LangChain4j 工具循环。 | |
| 24 | + */ | |
| 25 | +@Service | |
| 26 | +public class OllamaJsonClient { | |
| 27 | + | |
| 28 | + private static final Logger log = LoggerFactory.getLogger(OllamaJsonClient.class); | |
| 29 | + | |
| 30 | + private final ObjectMapper mapper; | |
| 31 | + | |
| 32 | + @Value("${langchain4j.ollama.base-url}") | |
| 33 | + private String baseUrl; | |
| 34 | + | |
| 35 | + @Value("${langchain4j.ollama.chat-model-name}") | |
| 36 | + private String model; | |
| 37 | + | |
| 38 | + public OllamaJsonClient(ObjectMapper mapper) { | |
| 39 | + this.mapper = mapper; | |
| 40 | + } | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * 受约束 JSON 补全:think=false(快且稳定,实测无精度损失)、低温度、format=schema、非流式。 | |
| 44 | + * | |
| 45 | + * @param system 系统提示(角色 + 抽取规则) | |
| 46 | + * @param user 用户话 | |
| 47 | + * @param schema JSON Schema(Map 结构,直接序列化进 format) | |
| 48 | + * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) | |
| 49 | + */ | |
| 50 | + public JsonNode completeJson(String system, String user, Map<String, Object> schema) { | |
| 51 | + try { | |
| 52 | + Map<String, Object> body = new LinkedHashMap<>(); | |
| 53 | + body.put("model", model); | |
| 54 | + body.put("stream", false); | |
| 55 | + body.put("think", false); | |
| 56 | + body.put("format", schema); | |
| 57 | + body.put("messages", List.of( | |
| 58 | + Map.of("role", "system", "content", system), | |
| 59 | + Map.of("role", "user", "content", user))); | |
| 60 | + Map<String, Object> opts = new LinkedHashMap<>(); | |
| 61 | + opts.put("temperature", 0.1); | |
| 62 | + opts.put("top_p", 0.9); | |
| 63 | + opts.put("num_ctx", 8192); // 足够容纳字段清单/历史,避免长上下文时工具/JSON 退化 | |
| 64 | + body.put("options", opts); | |
| 65 | + | |
| 66 | + String json = mapper.writeValueAsString(body); | |
| 67 | + String resp = OkHttpUtil.getInstance(10, 120, 30).postJson(baseUrl + "/api/chat", json); | |
| 68 | + JsonNode root = mapper.readTree(resp); | |
| 69 | + String content = root.path("message").path("content").asText(""); | |
| 70 | + if (content.isBlank()) { | |
| 71 | + log.warn("ollama json completion: empty content"); | |
| 72 | + return null; | |
| 73 | + } | |
| 74 | + return mapper.readTree(content); | |
| 75 | + } catch (Exception e) { | |
| 76 | + log.warn("ollama json completion failed: {}", e.getMessage()); | |
| 77 | + return null; | |
| 78 | + } | |
| 79 | + } | |
| 80 | +} | ... | ... |
src/main/java/com/xly/service/SlotFillService.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.xly.agent.Intent; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | + | |
| 7 | +import java.util.LinkedHashMap; | |
| 8 | +import java.util.List; | |
| 9 | +import java.util.Map; | |
| 10 | +import java.util.regex.Matcher; | |
| 11 | +import java.util.regex.Pattern; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 新增单据的**确定性槽位映射**:把意图门已抽好的「带角色实体」+ 正则解析的尺寸/数量, | |
| 15 | + * 按表单真实字段落位,产出 collectForm 的预填 JSON。 | |
| 16 | + * | |
| 17 | + * <p>为什么不用 LLM 做这步:实测 qwen3:14b 能可靠区分「产品 vs 客户」(意图门已给出角色), | |
| 18 | + * 但对「长和宽50cm,高5cm」这类**尺寸拆分**始终出错(塞进备注/单价)。这类窄任务用 Java 正则 + | |
| 19 | + * 角色映射远比让模型受约束填槽可靠——把不确定的判断交给能确定处理的代码,是治『不智能』的关键手法之一。 | |
| 20 | + */ | |
| 21 | +@Service | |
| 22 | +public class SlotFillService { | |
| 23 | + | |
| 24 | + private final ObjectMapper mapper; | |
| 25 | + | |
| 26 | + public SlotFillService(ObjectMapper mapper) { | |
| 27 | + this.mapper = mapper; | |
| 28 | + } | |
| 29 | + | |
| 30 | + // 标注式尺寸:"长和宽50"、"高5cm"、"长:50" | |
| 31 | + private static final Pattern DIM_LABELED = | |
| 32 | + Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)"); | |
| 33 | + // 位置式尺寸:"50*30*5"、"50x30x5"、"50×30×5" | |
| 34 | + private static final Pattern DIM_POS = | |
| 35 | + Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * 依据意图门的角色实体 + 正则,确定性地构建新增表单的预填字段(中文字段名 -> 值)。 | |
| 39 | + * | |
| 40 | + * @param utterance 用户原话(用于解析尺寸) | |
| 41 | + * @param entity 单据/实体类型(如 报价 / 客户) | |
| 42 | + * @param fields {@code FormResolverService.businessFields}(含 label/fk/type) | |
| 43 | + * @param it 意图门结果(提供带角色的实体) | |
| 44 | + */ | |
| 45 | + public Map<String, String> buildCreateFields(String utterance, String entity, | |
| 46 | + List<Map<String, Object>> fields, Intent it) { | |
| 47 | + Map<String, String> known = new LinkedHashMap<>(); | |
| 48 | + if (fields == null || fields.isEmpty()) { | |
| 49 | + return known; | |
| 50 | + } | |
| 51 | + // 干净字段名 -> 原始 label(供尺寸/数量按名定位) | |
| 52 | + Map<String, String> cleanToLabel = new LinkedHashMap<>(); | |
| 53 | + for (Map<String, Object> f : fields) { | |
| 54 | + String label = str(f.get("label")); | |
| 55 | + if (label != null && !label.isBlank()) { | |
| 56 | + cleanToLabel.putIfAbsent(cleanLabel(label), label); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + String cust = it == null ? null : it.firstValueByRole("客户"); | |
| 61 | + String prod = it == null ? null : it.firstValueByRole("产品"); | |
| 62 | + String mat = it == null ? null : it.firstValueByRole("物料"); | |
| 63 | + String sup = it == null ? null : it.firstValueByRole("供应商"); | |
| 64 | + | |
| 65 | + // 1) 外键角色字段:客户/产品/物料/供应商 → 对应 FK 字段 | |
| 66 | + for (Map<String, Object> f : fields) { | |
| 67 | + String fk = str(f.get("fk")); | |
| 68 | + String label = str(f.get("label")); | |
| 69 | + if (fk == null || label == null) continue; | |
| 70 | + if ("elecustomer".equals(fk) && cust != null) known.putIfAbsent(label, cust); | |
| 71 | + else if ("eleproduct".equals(fk) && prod != null) known.putIfAbsent(label, prod); | |
| 72 | + else if ("elematerials".equals(fk) && mat != null) known.putIfAbsent(label, mat); | |
| 73 | + else if ("elesupplier".equals(fk) && sup != null) known.putIfAbsent(label, sup); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // 2) 数量:意图门角色=数量 → 只留数字 | |
| 77 | + String qty = it == null ? null : it.firstValueByRole("数量"); | |
| 78 | + if (qty != null) { | |
| 79 | + String lbl = cleanToLabel.get("数量"); | |
| 80 | + if (lbl != null) { | |
| 81 | + String d = qty.replaceAll("[^0-9.]", ""); | |
| 82 | + if (!d.isEmpty()) known.putIfAbsent(lbl, d); | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + // 3) 尺寸:正则从原话解析 长/宽/高 | |
| 87 | + Map<String, String> dims = parseDimensions(utterance); | |
| 88 | + for (Map.Entry<String, String> e : dims.entrySet()) { | |
| 89 | + String lbl = cleanToLabel.get(e.getKey()); // "长"/"宽"/"高" | |
| 90 | + if (lbl != null) known.putIfAbsent(lbl, e.getValue()); | |
| 91 | + } | |
| 92 | + | |
| 93 | + // 4) 名称字段:新增某实体本身(如"新增客户叫X")时,实体名要落到它的名称字段 | |
| 94 | + // (客户表本身没有 elecustomer 外键列,故不会被上面的 FK 环填上,需在此补)。 | |
| 95 | + String nameLabel = findNameField(fields); | |
| 96 | + if (nameLabel != null && !known.containsKey(nameLabel)) { | |
| 97 | + String selfRole = danjuToRole(entity); | |
| 98 | + String nameVal = (selfRole != null && it != null) ? it.firstValueByRole(selfRole) : null; | |
| 99 | + if (nameVal == null && cust == null && prod == null && mat == null && sup == null) { | |
| 100 | + nameVal = firstEntityValue(it); // 兜底:单据类型不含角色词时用首个实体 | |
| 101 | + } | |
| 102 | + if (nameVal != null) { | |
| 103 | + known.put(nameLabel, nameVal); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + return known; | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** 单据/实体类型 → 它自身的业务角色(新增该实体时,该角色的实体值即其名称)。 */ | |
| 110 | + private static String danjuToRole(String entity) { | |
| 111 | + if (entity == null) return null; | |
| 112 | + if (entity.contains("客户")) return "客户"; | |
| 113 | + if (entity.contains("供应商")) return "供应商"; | |
| 114 | + if (entity.contains("物料")) return "物料"; | |
| 115 | + if (entity.contains("产品")) return "产品"; | |
| 116 | + return null; | |
| 117 | + } | |
| 118 | + | |
| 119 | + /** 解析尺寸 → {长,宽,高} 的数字(cm/mm 单位忽略,只取数值)。 */ | |
| 120 | + public Map<String, String> parseDimensions(String utterance) { | |
| 121 | + Map<String, String> out = new LinkedHashMap<>(); | |
| 122 | + if (utterance == null || utterance.isBlank()) return out; | |
| 123 | + Matcher m = DIM_LABELED.matcher(utterance); | |
| 124 | + boolean any = false; | |
| 125 | + while (m.find()) { | |
| 126 | + String labels = m.group(1); | |
| 127 | + String val = m.group(2); | |
| 128 | + for (int i = 0; i < labels.length(); i++) { | |
| 129 | + char c = labels.charAt(i); | |
| 130 | + String key = dimKey(c); | |
| 131 | + if (key != null) { | |
| 132 | + out.put(key, val); | |
| 133 | + any = true; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + } | |
| 137 | + if (!any) { | |
| 138 | + Matcher p = DIM_POS.matcher(utterance); | |
| 139 | + if (p.find()) { | |
| 140 | + out.put("长", p.group(1)); | |
| 141 | + out.put("宽", p.group(2)); | |
| 142 | + if (p.group(3) != null) out.put("高", p.group(3)); | |
| 143 | + } | |
| 144 | + } | |
| 145 | + return out; | |
| 146 | + } | |
| 147 | + | |
| 148 | + private static String dimKey(char c) { | |
| 149 | + switch (c) { | |
| 150 | + case '长': return "长"; | |
| 151 | + case '宽': return "宽"; | |
| 152 | + case '高': | |
| 153 | + case '厚': | |
| 154 | + case '深': return "高"; | |
| 155 | + default: return null; | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** 名称字段:优先 label 以「名称」结尾且非外键的字段;否则第一个 label 含「名称」的。 */ | |
| 160 | + private static String findNameField(List<Map<String, Object>> fields) { | |
| 161 | + for (Map<String, Object> f : fields) { | |
| 162 | + String label = str(f.get("label")); | |
| 163 | + String fk = str(f.get("fk")); | |
| 164 | + if (label != null && label.endsWith("名称") && (fk == null || fk.isBlank())) { | |
| 165 | + return label; | |
| 166 | + } | |
| 167 | + } | |
| 168 | + for (Map<String, Object> f : fields) { | |
| 169 | + String label = str(f.get("label")); | |
| 170 | + if (label != null && label.contains("名称")) return label; | |
| 171 | + } | |
| 172 | + return null; | |
| 173 | + } | |
| 174 | + | |
| 175 | + private static String firstEntityValue(Intent it) { | |
| 176 | + if (it == null) return null; | |
| 177 | + for (Intent.Entity e : it.entities) { | |
| 178 | + if (e != null && e.value != null && !e.value.isBlank()) return e.value.trim(); | |
| 179 | + } | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + /** 序列化为 knownFieldsJson,供 {@code FormCollectTool.collectForm} 预填。 */ | |
| 184 | + public String toJson(Map<String, String> known) { | |
| 185 | + try { | |
| 186 | + return mapper.writeValueAsString(known); | |
| 187 | + } catch (Exception e) { | |
| 188 | + return "{}"; | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + private static String cleanLabel(String s) { | |
| 193 | + if (s == null) return ""; | |
| 194 | + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); | |
| 195 | + } | |
| 196 | + | |
| 197 | + private static String str(Object o) { | |
| 198 | + return o == null ? null : o.toString(); | |
| 199 | + } | |
| 200 | +} | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| 1 | 1 | package com.xly.service; |
| 2 | 2 | |
| 3 | +import com.xly.agent.ToolScope; | |
| 3 | 4 | import org.springframework.jdbc.core.JdbcTemplate; |
| 4 | 5 | import org.springframework.stereotype.Service; |
| 5 | 6 | |
| ... | ... | @@ -7,12 +8,11 @@ import java.util.List; |
| 7 | 8 | import java.util.Map; |
| 8 | 9 | |
| 9 | 10 | /** |
| 10 | - * 构建单 agent 的 system prompt。 | |
| 11 | + * 构建 agent 的 system prompt——**按工具范围(ToolScope)分版**。 | |
| 11 | 12 | * |
| 12 | - * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)与 | |
| 13 | - * Skill 摘要({@code ai_skill} 的 name+何时用)渲染进 system prompt,作为常驻的「路由地图 + 技能目录」—— | |
| 14 | - * 让单 agent 先判断问题属于哪个业务域/是否命中某 Skill,再决定调哪个工具。L1/Skill 摘要体量小且永远相关, | |
| 15 | - * 适合常驻;L2/L3 与 Skill 详情大而稀疏,走工具(KgSearch / load_skill)按需查。 | |
| 13 | + * <p>新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。 | |
| 14 | + * 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。 | |
| 15 | + * 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。 | |
| 16 | 16 | */ |
| 17 | 17 | @Service |
| 18 | 18 | public class SystemPromptService { |
| ... | ... | @@ -20,59 +20,105 @@ public class SystemPromptService { |
| 20 | 20 | private final JdbcTemplate jdbc; |
| 21 | 21 | private final SkillService skills; |
| 22 | 22 | |
| 23 | + private volatile String readPrompt; | |
| 24 | + private volatile String writePrompt; | |
| 25 | + private volatile String fullPrompt; | |
| 26 | + | |
| 23 | 27 | public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { |
| 24 | 28 | this.jdbc = jdbc; |
| 25 | 29 | this.skills = skills; |
| 26 | 30 | } |
| 27 | 31 | |
| 32 | + private static final String HEADER = | |
| 33 | + "【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**," | |
| 34 | + + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" | |
| 35 | + + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; | |
| 36 | + | |
| 37 | + /** 兼容旧入口:默认给 FULL 版。 */ | |
| 28 | 38 | public String buildSystemPrompt() { |
| 29 | - return """ | |
| 30 | - 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 | |
| 39 | + return buildPrompt(ToolScope.FULL); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public String buildPrompt(ToolScope scope) { | |
| 43 | + switch (scope) { | |
| 44 | + case READ: | |
| 45 | + if (readPrompt == null) readPrompt = renderRead(); | |
| 46 | + return readPrompt; | |
| 47 | + case WRITE: | |
| 48 | + if (writePrompt == null) writePrompt = renderWrite(); | |
| 49 | + return writePrompt; | |
| 50 | + default: | |
| 51 | + if (fullPrompt == null) fullPrompt = renderFull(); | |
| 52 | + return fullPrompt; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + private String renderRead() { | |
| 57 | + return HEADER + """ | |
| 58 | + | |
| 59 | + 本轮任务是**查询 / 读取**数据(不做任何写操作)。 | |
| 60 | + | |
| 61 | + 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 | |
| 62 | + %s | |
| 63 | + 【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做: | |
| 64 | + %s | |
| 65 | + 【可用工具(只有这些)】 | |
| 66 | + - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 | |
| 67 | + - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。 | |
| 68 | + - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 | |
| 69 | + - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。 | |
| 70 | + - queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。 | |
| 71 | + - loadSkill(name)、askUser(question, options?)。 | |
| 72 | + | |
| 73 | + 【准则】 | |
| 74 | + 1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。 | |
| 75 | + 2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。 | |
| 76 | + 3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。 | |
| 77 | + """.formatted(renderDomainMap(), renderSkills()); | |
| 78 | + } | |
| 79 | + | |
| 80 | + private String renderWrite() { | |
| 81 | + return HEADER + """ | |
| 31 | 82 | |
| 32 | - 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ | |
| 33 | - 帮助他们查询和操作 ERP 里的业务单据。 | |
| 83 | + 本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、 | |
| 84 | + 绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。 | |
| 34 | 85 | |
| 35 | - 【业务域地图(L1 路由)】 | |
| 36 | - 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ | |
| 37 | - 可能涉及哪些单据,再决定怎么做: | |
| 86 | + 【可用工具(只有这些)】 | |
| 87 | + - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。 | |
| 88 | + - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作: | |
| 89 | + · create=新增(用 fieldsJson,通常在用户表单提交后) | |
| 90 | + · update=改某字段(用 recordKeyword+fieldChinese+newValue) | |
| 91 | + · invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废 | |
| 92 | + · examine=审核;cancelExamine=销审/反审核 | |
| 93 | + · delete=物理删除(明细行/极少用,业务单据别用) | |
| 94 | + 它自行定位主表与记录,无需先 findForms。 | |
| 95 | + - lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。 | |
| 96 | + - askUser(question, options?)、loadSkill(name)。 | |
| 97 | + | |
| 98 | + 【准则】 | |
| 99 | + 1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。 | |
| 100 | + 2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。 | |
| 101 | + 3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。 | |
| 102 | + """.formatted(); | |
| 103 | + } | |
| 104 | + | |
| 105 | + private String renderFull() { | |
| 106 | + // 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。 | |
| 107 | + return HEADER + """ | |
| 108 | + | |
| 109 | + 【业务域地图】 | |
| 38 | 110 | %s |
| 39 | - 【可用技能 Skills】 | |
| 40 | - 下面是一些常见任务的技能。若用户需求命中某个技能的「何时用」,先调用 loadSkill(该技能名) 拿到详细步骤再照做: | |
| 111 | + 【可用技能】命中「何时用」时先 loadSkill(技能名): | |
| 41 | 112 | %s |
| 42 | - 【可用工具】 | |
| 43 | - - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 | |
| 44 | - - kgSearch(keyword):查知识图谱——某表单的上下游流转、相邻单据、字段所在的表/列。想弄清「这张单从哪来、到哪去」或「某字段在哪张表」时用。 | |
| 45 | - - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 | |
| 46 | - - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ | |
| 47 | - 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 | |
| 48 | - - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ | |
| 49 | - 仅当 readFormData / lookupRecord 都答不了时才用。 | |
| 50 | - - askUser(question, options?):意图不明或需二选一时,向用户提一个带选项的小问题;问完即等用户回答。 | |
| 51 | - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐。\ | |
| 52 | - 把用户已说的信息作为 knownFieldsJson 预填(字段中文名->值,如 {"产品名称":"纸盒","数量":"1000","长(L)":"50"}),\ | |
| 53 | - 客户/产品/物料等会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,提交后你再用 proposeCreate。 | |
| 54 | - - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ | |
| 55 | - fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。\ | |
| 56 | - **仅当字段(尤其客户/产品)都由用户明确给出、无需猜测时才直接用它**;否则先 collectForm。 | |
| 57 | - - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段(写操作,只提议、暂不执行)。 | |
| 58 | - - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行)。删除不可恢复,慎用。 | |
| 59 | - - proposeExamine(entityKeyword, recordKeyword):**提议审核/过账**某张单据(写操作,只提议、暂不执行)。审核有业务后果,慎用。 | |
| 60 | - - loadSkill(name):加载某个业务技能的完整操作指南(见上方 Skills 清单)。 | |
| 61 | - 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ | |
| 62 | - (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ | |
| 63 | - (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 | |
| 64 | - | |
| 65 | - 【行为准则】 | |
| 66 | - 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | |
| 67 | - 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 | |
| 68 | - 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 | |
| 69 | - 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete、审核用 proposeExamine。\ | |
| 70 | - 它们都**只生成待确认提议、不立即执行**;真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。 | |
| 71 | - 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ | |
| 72 | - 只有确实缺少关键参数(如不知道要查哪张单据)时才用 askUser 提问。 | |
| 73 | - 6. **新增报价**("报价X"/"给X报价"/"新增报价")**一律先 collectForm**,把用户说的产品/数量/尺寸作为 knownFieldsJson 预填,\ | |
| 74 | - 客户/产品让用户在下拉里选真实数据。**绝不要把产品名、盒型、规格猜成客户名称**——"报价纸盒"里"纸盒"是产品不是客户;\ | |
| 75 | - 客户未知就交给表单让用户选,不要自己编、也不要因为找不到就提议新建客户。 | |
| 113 | + 【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData; | |
| 114 | + 写(人在环,仅提议):collectForm/proposeCreate/proposeUpdate/proposeDelete/proposeExamine; | |
| 115 | + 以及 askUser/loadSkill。 | |
| 116 | + | |
| 117 | + 【准则】 | |
| 118 | + 1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。 | |
| 119 | + 2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm。 | |
| 120 | + 3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。 | |
| 121 | + 4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。 | |
| 76 | 122 | """.formatted(renderDomainMap(), renderSkills()); |
| 77 | 123 | } |
| 78 | 124 | |
| ... | ... | @@ -88,7 +134,6 @@ public class SystemPromptService { |
| 88 | 134 | "SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " + |
| 89 | 135 | "FROM viw_kg_domain ORDER BY iForms DESC"); |
| 90 | 136 | } catch (Exception e) { |
| 91 | - // KG 视图缺失时不应阻断启动——降级为空域图,agent 仍可运行。 | |
| 92 | 137 | org.slf4j.LoggerFactory.getLogger(SystemPromptService.class) |
| 93 | 138 | .warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage()); |
| 94 | 139 | return "(业务域地图暂不可用)\n"; | ... | ... |
src/main/java/com/xly/tool/ProposeWriteTool.java
| ... | ... | @@ -43,14 +43,96 @@ public class ProposeWriteTool { |
| 43 | 43 | this.resolver = resolver; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | - @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" | |
| 47 | - + "必须等用户在对话内点【确认】后才真正修改。用于「把某个客户/物料的某字段改成X」这类需求。" | |
| 48 | - + "直接给出实体类型(如 客户)即可,本工具会自行定位主表,无需先 findForms。") | |
| 49 | - public String proposeUpdate( | |
| 50 | - @P("实体/单据类型关键词,如 客户 / 物料 / 供应商") String entityKeyword, | |
| 51 | - @P("要修改的那条记录的名称关键词(如某个客户名)") String recordKeyword, | |
| 52 | - @P("要修改的字段中文名(如 简称 / 备注 / 联系电话)") String fieldChinese, | |
| 53 | - @P("修改后的新值") String newValue) { | |
| 46 | + @Tool("提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。" | |
| 47 | + + "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);" | |
| 48 | + + "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。" | |
| 49 | + + "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;" | |
| 50 | + + "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。") | |
| 51 | + public String proposeWrite( | |
| 52 | + @P("动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete") String action, | |
| 53 | + @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword, | |
| 54 | + @P(value = "记录名称或单号关键词(create 不需要)", required = false) String recordKeyword, | |
| 55 | + @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese, | |
| 56 | + @P(value = "新值(仅 update)", required = false) String newValue, | |
| 57 | + @P(value = "字段 JSON 中文名->值(仅 create)", required = false) String fieldsJson) { | |
| 58 | + if (isBlank(action)) { | |
| 59 | + return err("缺少 action。"); | |
| 60 | + } | |
| 61 | + switch (action.trim().toLowerCase()) { | |
| 62 | + case "create": return doCreate(entityKeyword, fieldsJson); | |
| 63 | + case "update": return doUpdate(entityKeyword, recordKeyword, fieldChinese, newValue); | |
| 64 | + case "invalid": return doInvalidate(entityKeyword, recordKeyword, false); | |
| 65 | + case "cancelinvalid": return doInvalidate(entityKeyword, recordKeyword, true); | |
| 66 | + case "examine": return doExamineOp(entityKeyword, recordKeyword, 1); | |
| 67 | + case "cancelexamine": return doExamineOp(entityKeyword, recordKeyword, 0); | |
| 68 | + case "delete": return doDelete(entityKeyword, recordKeyword); | |
| 69 | + default: | |
| 70 | + return err("未知 action:" + action + "(支持 create/update/invalid/cancelInvalid/examine/cancelExamine/delete)"); | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** 作废(cancel=false)/复原·取消作废(cancel=true)一条记录 → ERP updatebInvalid(Sp_Invalidation)。业务单据的“删除”走这里。 */ | |
| 75 | + private String doInvalidate(String entityKeyword, String recordKeyword, boolean cancel) { | |
| 76 | + String verb = cancel ? "复原" : "作废"; | |
| 77 | + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { | |
| 78 | + return err("缺少实体类型或记录名称。"); | |
| 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 + "。"); | |
| 120 | + } | |
| 121 | + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | |
| 122 | + String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; | |
| 123 | + // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid | |
| 124 | + String opId = ops.createDraft(identity.userId(), "invalid", formId, moduleId, table, billId, | |
| 125 | + null, null, null, cancel ? "cancel" : "toVoid", description); | |
| 126 | + | |
| 127 | + Map<String, Object> out = new LinkedHashMap<>(); | |
| 128 | + out.put("opId", opId); | |
| 129 | + out.put("summary", description); | |
| 130 | + out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。"); | |
| 131 | + return toJson(out); | |
| 132 | + } | |
| 133 | + | |
| 134 | + /** 修改某条记录的某个字段。见 {@link #proposeWrite}。 */ | |
| 135 | + private String doUpdate(String entityKeyword, String recordKeyword, String fieldChinese, String newValue) { | |
| 54 | 136 | |
| 55 | 137 | if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) { |
| 56 | 138 | return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); |
| ... | ... | @@ -130,11 +212,8 @@ public class ProposeWriteTool { |
| 130 | 212 | return toJson(out); |
| 131 | 213 | } |
| 132 | 214 | |
| 133 | - @Tool("提议**删除**某条现有记录(写操作)。本工具只提议并暂存、绝不立即执行——必须等用户点【确认】后才真正删除。" | |
| 134 | - + "删除不可恢复,请慎用。直接给出实体类型与记录名即可。") | |
| 135 | - public String proposeDelete( | |
| 136 | - @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, | |
| 137 | - @P("要删除的记录的名称关键词") String recordKeyword) { | |
| 215 | + /** 物理删除一条记录(罕用;业务单据请用 doInvalidate 作废)。见 {@link #proposeWrite}。 */ | |
| 216 | + private String doDelete(String entityKeyword, String recordKeyword) { | |
| 138 | 217 | |
| 139 | 218 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { |
| 140 | 219 | return err("缺少实体类型或记录名称。"); |
| ... | ... | @@ -192,11 +271,8 @@ public class ProposeWriteTool { |
| 192 | 271 | return toJson(out); |
| 193 | 272 | } |
| 194 | 273 | |
| 195 | - @Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。" | |
| 196 | - + "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。") | |
| 197 | - public String proposeCreate( | |
| 198 | - @P("实体类型,如 客户 / 物料") String entityKeyword, | |
| 199 | - @P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) { | |
| 274 | + /** 新增一条记录(报价走多表 proposeQuote)。见 {@link #proposeWrite}。 */ | |
| 275 | + private String doCreate(String entityKeyword, String fieldsJson) { | |
| 200 | 276 | |
| 201 | 277 | if (isBlank(entityKeyword)) { |
| 202 | 278 | return err("缺少实体类型。"); |
| ... | ... | @@ -304,11 +380,8 @@ public class ProposeWriteTool { |
| 304 | 380 | return toJson(out); |
| 305 | 381 | } |
| 306 | 382 | |
| 307 | - @Tool("提议**审核/过账**某条单据(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正审核。" | |
| 308 | - + "用于「审核 / 过账某张单据」这类需求;审核有业务后果,请谨慎。给出单据类型与单号/名称即可。") | |
| 309 | - public String proposeExamine( | |
| 310 | - @P("单据类型,如 销售订单 / 采购订单 / 报价") String entityKeyword, | |
| 311 | - @P("要审核的单据编号或名称关键词") String recordKeyword) { | |
| 383 | + /** 审核(iFlag=1)/反审核·销审(iFlag=0)一条单据 → ERP doExamine。见 {@link #proposeWrite}。 */ | |
| 384 | + private String doExamineOp(String entityKeyword, String recordKeyword, int iFlag) { | |
| 312 | 385 | |
| 313 | 386 | if (isBlank(entityKeyword) || isBlank(recordKeyword)) { |
| 314 | 387 | return err("缺少单据类型或单号/名称。"); |
| ... | ... | @@ -355,15 +428,16 @@ public class ProposeWriteTool { |
| 355 | 428 | return err("定位到的单据缺少主键 sId,无法审核。"); |
| 356 | 429 | } |
| 357 | 430 | String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); |
| 358 | - String description = "审核【" + recordName + "】(" + entityKeyword + ")"; | |
| 359 | - // examine:sNewValue 存 iFlag(1=审核);执行走 ERP doExamine(存储过程驱动) | |
| 431 | + String verb = iFlag == 1 ? "审核" : "反审核"; | |
| 432 | + String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; | |
| 433 | + // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) | |
| 360 | 434 | String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, |
| 361 | - null, null, null, "1", description); | |
| 435 | + null, null, null, String.valueOf(iFlag), description); | |
| 362 | 436 | |
| 363 | 437 | Map<String, Object> out = new LinkedHashMap<>(); |
| 364 | 438 | out.put("opId", opId); |
| 365 | 439 | out.put("summary", description); |
| 366 | - out.put("message", "已为你生成一条待确认的审核,请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); | |
| 440 | + out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); | |
| 367 | 441 | return toJson(out); |
| 368 | 442 | } |
| 369 | 443 | ... | ... |
src/main/java/com/xly/tool/QueryTool.java
| ... | ... | @@ -2,6 +2,7 @@ package com.xly.tool; |
| 2 | 2 | |
| 3 | 3 | import com.xly.agent.AgentIdentity; |
| 4 | 4 | import com.xly.service.AuditService; |
| 5 | +import com.xly.service.FormResolverService; | |
| 5 | 6 | import dev.langchain4j.agent.tool.P; |
| 6 | 7 | import dev.langchain4j.agent.tool.Tool; |
| 7 | 8 | import dev.langchain4j.model.ollama.OllamaChatModel; |
| ... | ... | @@ -18,10 +19,12 @@ import net.sf.jsqlparser.util.TablesNamesFinder; |
| 18 | 19 | import org.springframework.jdbc.core.JdbcTemplate; |
| 19 | 20 | |
| 20 | 21 | import java.util.HashSet; |
| 22 | +import java.util.LinkedHashMap; | |
| 21 | 23 | import java.util.List; |
| 22 | 24 | import java.util.Map; |
| 23 | 25 | import java.util.Set; |
| 24 | 26 | import java.util.concurrent.ConcurrentHashMap; |
| 27 | +import java.util.regex.Pattern; | |
| 25 | 28 | |
| 26 | 29 | /** |
| 27 | 30 | * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 |
| ... | ... | @@ -37,12 +40,15 @@ public class QueryTool { |
| 37 | 40 | private final JdbcTemplate jdbc; |
| 38 | 41 | private final AuditService audit; |
| 39 | 42 | private final AgentIdentity identity; |
| 43 | + private final FormResolverService resolver; | |
| 40 | 44 | |
| 41 | - public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity) { | |
| 45 | + public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, | |
| 46 | + AgentIdentity identity, FormResolverService resolver) { | |
| 42 | 47 | this.sqlModel = sqlModel; |
| 43 | 48 | this.jdbc = jdbc; |
| 44 | 49 | this.audit = audit; |
| 45 | 50 | this.identity = identity; |
| 51 | + this.resolver = resolver; | |
| 46 | 52 | } |
| 47 | 53 | |
| 48 | 54 | @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" |
| ... | ... | @@ -98,6 +104,8 @@ public class QueryTool { |
| 98 | 104 | 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表; |
| 99 | 105 | 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据); |
| 100 | 106 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; |
| 107 | + **给用户看的是名称不是内部ID**:若按某实体(产品/客户/物料/供应商)分组或排名,必须 JOIN 该实体主表、 | |
| 108 | + 在结果里返回它的**名称列**(如 eleproduct.sProductName、elecustomer.sCustomerName),不要只返回 *Id 列; | |
| 101 | 109 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 |
| 102 | 110 | 问题:%s%s |
| 103 | 111 | """.formatted(hint, brandHint(), question, repair); |
| ... | ... | @@ -313,6 +321,60 @@ public class QueryTool { |
| 313 | 321 | return sql; |
| 314 | 322 | } |
| 315 | 323 | |
| 324 | + // 裸主键ID(业务 sId 通常为 15+ 位数字串)。仅对严格匹配的长数字尝试解析,普通数字(数量/金额)不受影响。 | |
| 325 | + private static final Pattern LONG_ID = Pattern.compile("\\d{15,}"); | |
| 326 | + private static volatile Map<String, String> NAME_TABLES; // 主表 -> 名称字段(静态缓存) | |
| 327 | + private final Map<String, String> idNameCache = new ConcurrentHashMap<>(); // 本次请求内的 id->名称 缓存 | |
| 328 | + | |
| 329 | + /** 被外键引用的主表集合及其名称字段(客户/产品/物料/供应商…)。用于把裸ID回解析成名称。 */ | |
| 330 | + private Map<String, String> nameTables() { | |
| 331 | + Map<String, String> c = NAME_TABLES; | |
| 332 | + if (c != null) { | |
| 333 | + return c; | |
| 334 | + } | |
| 335 | + Map<String, String> m = new LinkedHashMap<>(); | |
| 336 | + try { | |
| 337 | + for (Map<String, Object> r : jdbc.queryForList( | |
| 338 | + "SELECT DISTINCT sFkTable FROM viw_kg_field_dict WHERE IFNULL(sFkTable,'')<>'' " + | |
| 339 | + "AND sFkTable NOT LIKE 'viw%'")) { | |
| 340 | + String t = String.valueOf(r.get("sFkTable")); | |
| 341 | + if (t == null || t.isBlank()) continue; | |
| 342 | + String nf = resolver.resolveNameField(t); | |
| 343 | + if (nf != null && !nf.isBlank()) { | |
| 344 | + m.put(t, nf); | |
| 345 | + } | |
| 346 | + } | |
| 347 | + } catch (Exception ignore) { | |
| 348 | + } | |
| 349 | + NAME_TABLES = m; | |
| 350 | + return m; | |
| 351 | + } | |
| 352 | + | |
| 353 | + /** 长ID → 名称(在被引用主表里按 sId 命中即返回;找不到返回 null,保留原ID)。 */ | |
| 354 | + private String resolveId(String val) { | |
| 355 | + String cached = idNameCache.get(val); | |
| 356 | + if (cached != null) { | |
| 357 | + return cached.isEmpty() ? null : cached; | |
| 358 | + } | |
| 359 | + for (Map.Entry<String, String> e : nameTables().entrySet()) { | |
| 360 | + try { | |
| 361 | + List<Map<String, Object>> r = jdbc.queryForList( | |
| 362 | + "SELECT `" + e.getValue() + "` nm FROM `" + e.getKey() + "` WHERE sId=? LIMIT 1", val); | |
| 363 | + if (!r.isEmpty()) { | |
| 364 | + Object nm = r.get(0).get("nm"); | |
| 365 | + if (nm != null && !String.valueOf(nm).isBlank()) { | |
| 366 | + String s = String.valueOf(nm); | |
| 367 | + idNameCache.put(val, s); | |
| 368 | + return s; | |
| 369 | + } | |
| 370 | + } | |
| 371 | + } catch (Exception ignore) { | |
| 372 | + } | |
| 373 | + } | |
| 374 | + idNameCache.put(val, ""); | |
| 375 | + return null; | |
| 376 | + } | |
| 377 | + | |
| 316 | 378 | private String formatRows(List<Map<String, Object>> rows) { |
| 317 | 379 | if (rows.isEmpty()) { |
| 318 | 380 | return "查询完成,没有匹配的数据。"; |
| ... | ... | @@ -332,6 +394,11 @@ public class QueryTool { |
| 332 | 394 | for (String c : cols) { |
| 333 | 395 | Object v = r.get(c); |
| 334 | 396 | String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); |
| 397 | + // 把裸的长ID(如产品/客户 sId)就地解析成名称,给用户看名字不是ID | |
| 398 | + if (LONG_ID.matcher(s).matches()) { | |
| 399 | + String nm = resolveId(s); | |
| 400 | + if (nm != null) s = nm; | |
| 401 | + } | |
| 335 | 402 | if (s.length() > 30) { |
| 336 | 403 | s = s.substring(0, 30) + "…"; |
| 337 | 404 | } | ... | ... |
src/main/java/com/xly/web/AgentChatController.java
| ... | ... | @@ -3,11 +3,17 @@ package com.xly.web; |
| 3 | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | 5 | import com.xly.agent.AgentIdentity; |
| 6 | +import com.xly.agent.Intent; | |
| 6 | 7 | import com.xly.agent.ReActAgent; |
| 8 | +import com.xly.agent.ToolScope; | |
| 7 | 9 | import com.xly.config.AgentFactory; |
| 8 | 10 | import com.xly.service.AuthzService; |
| 9 | 11 | import com.xly.service.ConversationService; |
| 12 | +import com.xly.service.FormResolverService; | |
| 13 | +import com.xly.service.IntentService; | |
| 10 | 14 | import com.xly.service.OpService; |
| 15 | +import com.xly.service.SlotFillService; | |
| 16 | +import com.xly.tool.FormCollectTool; | |
| 11 | 17 | import dev.langchain4j.service.TokenStream; |
| 12 | 18 | import dev.langchain4j.service.tool.ToolExecution; |
| 13 | 19 | import org.slf4j.Logger; |
| ... | ... | @@ -21,6 +27,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| 21 | 27 | |
| 22 | 28 | import java.io.IOException; |
| 23 | 29 | import java.util.LinkedHashMap; |
| 30 | +import java.util.List; | |
| 24 | 31 | import java.util.Map; |
| 25 | 32 | import java.util.Set; |
| 26 | 33 | import java.util.concurrent.ExecutorService; |
| ... | ... | @@ -29,35 +36,51 @@ import java.util.concurrent.Executors; |
| 29 | 36 | /** |
| 30 | 37 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 |
| 31 | 38 | * |
| 32 | - * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、 | |
| 33 | - * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。 | |
| 34 | - * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 | |
| 39 | + * <p><b>新编排(架构 §5 意图门 + 确定性路由)</b>:不再让一个弱模型在 12 个工具里一次性盲选。 | |
| 40 | + * 每轮先用 {@link IntentService} 做**受约束 JSON** 的意图+实体分类,再确定性路由: | |
| 41 | + * <ul> | |
| 42 | + * <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单 | |
| 43 | + * (完全不经 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> | |
| 47 | + * </ul> | |
| 48 | + * 收窄工具集 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 | |
| 35 | 49 | * |
| 36 | - * <p>**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份, | |
| 37 | - * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。 | |
| 50 | + * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 | |
| 51 | + * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 | |
| 38 | 52 | */ |
| 39 | 53 | @RestController |
| 40 | 54 | @RequestMapping("/api/agent") |
| 41 | 55 | public class AgentChatController { |
| 42 | 56 | |
| 43 | 57 | private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); |
| 44 | - private static final Set<String> WRITE_TOOLS = | |
| 45 | - Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine"); | |
| 58 | + private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite"); | |
| 59 | + /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ | |
| 60 | + private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; | |
| 46 | 61 | |
| 47 | 62 | private final AgentFactory agentFactory; |
| 48 | 63 | private final AuthzService authz; |
| 49 | 64 | private final ObjectMapper mapper; |
| 50 | 65 | private final ConversationService conversations; |
| 51 | 66 | private final OpService ops; |
| 67 | + private final IntentService intentService; | |
| 68 | + private final SlotFillService slotFill; | |
| 69 | + private final FormResolverService resolver; | |
| 52 | 70 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 53 | 71 | |
| 54 | 72 | public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, |
| 55 | - ConversationService conversations, OpService ops) { | |
| 73 | + ConversationService conversations, OpService ops, | |
| 74 | + IntentService intentService, SlotFillService slotFill, | |
| 75 | + FormResolverService resolver) { | |
| 56 | 76 | this.agentFactory = agentFactory; |
| 57 | 77 | this.authz = authz; |
| 58 | 78 | this.mapper = mapper; |
| 59 | 79 | this.conversations = conversations; |
| 60 | 80 | this.ops = ops; |
| 81 | + this.intentService = intentService; | |
| 82 | + this.slotFill = slotFill; | |
| 83 | + this.resolver = resolver; | |
| 61 | 84 | } |
| 62 | 85 | |
| 63 | 86 | public static class ChatReq { |
| ... | ... | @@ -81,27 +104,13 @@ public class AgentChatController { |
| 81 | 104 | : ((req.userid == null ? "anon" : req.userid) + ":default"); |
| 82 | 105 | |
| 83 | 106 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); |
| 84 | - | |
| 85 | 107 | final AgentIdentity identity = resolveIdentity(req); |
| 86 | - final ReActAgent agent = agentFactory.build(identity); | |
| 87 | 108 | |
| 88 | 109 | exec.submit(() -> { |
| 89 | 110 | try { |
| 90 | - TokenStream ts = agent.chat(convId, userInput); | |
| 91 | - ts.onPartialResponse(token -> send(emitter, "token", token)) | |
| 92 | - .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) | |
| 93 | - .onCompleteResponse(resp -> { | |
| 94 | - send(emitter, "done", ""); | |
| 95 | - emitter.complete(); | |
| 96 | - }) | |
| 97 | - .onError(err -> { | |
| 98 | - log.warn("agent chat stream error (conv={})", convId, err); | |
| 99 | - send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); | |
| 100 | - emitter.complete(); | |
| 101 | - }) | |
| 102 | - .start(); | |
| 111 | + route(emitter, convId, identity, userInput); | |
| 103 | 112 | } catch (Exception e) { |
| 104 | - log.error("agent invoke failed (conv={})", convId, e); | |
| 113 | + log.error("agent route failed (conv={})", convId, e); | |
| 105 | 114 | send(emitter, "error", "服务异常:" + e.getMessage()); |
| 106 | 115 | emitter.complete(); |
| 107 | 116 | } |
| ... | ... | @@ -109,6 +118,241 @@ public class AgentChatController { |
| 109 | 118 | return emitter; |
| 110 | 119 | } |
| 111 | 120 | |
| 121 | + /** 意图门 + 确定性路由。 */ | |
| 122 | + private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { | |
| 123 | + // 0) 表单提交 → 直接走写(执行 proposeCreate),不再重新分类。 | |
| 124 | + if (userInput.contains(FORM_SUBMIT_MARK)) { | |
| 125 | + runAgent(emitter, convId, identity, ToolScope.WRITE, userInput); | |
| 126 | + return; | |
| 127 | + } | |
| 128 | + // 1) 意图门(失败时返回 UNCLEAR,走兜底)。 | |
| 129 | + Intent it = intentService.classify(userInput); | |
| 130 | + log.info("intent(conv={}): {} / {} / entities={} / missing={}", | |
| 131 | + convId, it.intent, it.danju, it.describeEntities(), it.missing); | |
| 132 | + | |
| 133 | + switch (it.intent) { | |
| 134 | + case Intent.CREATE: | |
| 135 | + if (handleCreate(emitter, convId, identity, userInput, it)) { | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + // 无法确定性建表单 → 交给写 agent 处理(可能需要它先问清单据类型)。 | |
| 139 | + runAgent(emitter, convId, identity, ToolScope.WRITE, ground(userInput, it)); | |
| 140 | + return; | |
| 141 | + case Intent.UPDATE: | |
| 142 | + case Intent.DELETE: | |
| 143 | + case Intent.EXAMINE: | |
| 144 | + handleWrite(emitter, convId, identity, userInput, it); | |
| 145 | + return; | |
| 146 | + case Intent.QUERY: | |
| 147 | + runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it)); | |
| 148 | + return; | |
| 149 | + case Intent.CHAT: | |
| 150 | + runAgent(emitter, convId, identity, ToolScope.READ, userInput); | |
| 151 | + return; | |
| 152 | + case Intent.UNCLEAR: | |
| 153 | + default: | |
| 154 | + // 分类不确定/失败:给全部工具、原文交给 agent,尽量不丢能力。 | |
| 155 | + runAgent(emitter, convId, identity, ToolScope.FULL, userInput); | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 160 | + * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, | |
| 161 | + * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 | |
| 162 | + */ | |
| 163 | + private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity, | |
| 164 | + String userInput, Intent it) { | |
| 165 | + String entity = it.danju == null ? "" : it.danju.trim(); | |
| 166 | + if (entity.isEmpty()) { | |
| 167 | + return false; | |
| 168 | + } | |
| 169 | + Map<String, Object> form = resolver.resolveMasterForm(entity); | |
| 170 | + if (form == null) { | |
| 171 | + return false; | |
| 172 | + } | |
| 173 | + String table = String.valueOf(form.get("sDataSource")); | |
| 174 | + // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。 | |
| 175 | + List<Map<String, Object>> fields = resolver.businessFields(table, 40); | |
| 176 | + Map<String, String> known = slotFill.buildCreateFields(userInput, entity, fields, it); | |
| 177 | + | |
| 178 | + FormCollectTool fct = agentFactory.formCollectTool(identity); | |
| 179 | + String payload = fct.collectForm(entity, slotFill.toJson(known)); | |
| 180 | + try { | |
| 181 | + JsonNode r = mapper.readTree(payload); | |
| 182 | + if ("form_collect".equals(r.path("type").asText(""))) { | |
| 183 | + sendEvent(emitter, mapper.convertValue(r, Map.class)); | |
| 184 | + send(emitter, "token", "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"); | |
| 185 | + send(emitter, "done", ""); | |
| 186 | + emitter.complete(); | |
| 187 | + return true; | |
| 188 | + } | |
| 189 | + // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。 | |
| 190 | + String err = r.path("error").asText(""); | |
| 191 | + if (!err.isBlank()) { | |
| 192 | + send(emitter, "token", err); | |
| 193 | + send(emitter, "done", ""); | |
| 194 | + emitter.complete(); | |
| 195 | + return true; | |
| 196 | + } | |
| 197 | + } catch (Exception e) { | |
| 198 | + log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage()); | |
| 199 | + } | |
| 200 | + return false; | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** 运行某一范围的 ReAct agent,把流式回调转成 SSE。 */ | |
| 204 | + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, | |
| 205 | + ToolScope scope, String text) { | |
| 206 | + try { | |
| 207 | + ReActAgent agent = agentFactory.build(identity, scope); | |
| 208 | + TokenStream ts = agent.chat(convId, text); | |
| 209 | + ts.onPartialResponse(token -> send(emitter, "token", token)) | |
| 210 | + .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) | |
| 211 | + .onCompleteResponse(resp -> { | |
| 212 | + send(emitter, "done", ""); | |
| 213 | + emitter.complete(); | |
| 214 | + }) | |
| 215 | + .onError(err -> { | |
| 216 | + log.warn("agent chat stream error (conv={})", convId, err); | |
| 217 | + send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); | |
| 218 | + emitter.complete(); | |
| 219 | + }) | |
| 220 | + .start(); | |
| 221 | + } catch (Exception e) { | |
| 222 | + log.error("agent invoke failed (conv={})", convId, e); | |
| 223 | + send(emitter, "error", "服务异常:" + e.getMessage()); | |
| 224 | + emitter.complete(); | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ | |
| 229 | + private String ground(String userInput, Intent it) { | |
| 230 | + StringBuilder g = new StringBuilder(userInput); | |
| 231 | + g.append("\n\n(意图分析,仅供参考:意图=").append(it.intent); | |
| 232 | + if (it.danju != null && !it.danju.isBlank()) { | |
| 233 | + g.append(",单据/实体类型=").append(it.danju); | |
| 234 | + } | |
| 235 | + // 用 角色=值 的形式,避免下游把"值(角色)"整体误当作参数值 | |
| 236 | + StringBuilder pairs = new StringBuilder(); | |
| 237 | + for (Intent.Entity e : it.entities) { | |
| 238 | + if (e == null || e.value == null || e.value.isBlank()) continue; | |
| 239 | + if (pairs.length() > 0) pairs.append(","); | |
| 240 | + pairs.append(e.role == null ? "其他" : e.role).append("=").append(e.value); | |
| 241 | + } | |
| 242 | + if (pairs.length() > 0) { | |
| 243 | + g.append(",识别到 ").append(pairs); | |
| 244 | + } | |
| 245 | + g.append("。调用工具时请用上面的**值**作为参数(不要把角色名或括号写进参数),实体角色不要弄错。)"); | |
| 246 | + return g.toString(); | |
| 247 | + } | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * 写操作(修改/删除/审核)处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), | |
| 251 | + * 齐了就带干净 grounding 交给写 agent 直接 propose*(它会自定位主表/记录), | |
| 252 | + * 缺了就**确定性问一次**并停下——两头都不进失控循环。 | |
| 253 | + */ | |
| 254 | + private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, | |
| 255 | + String userInput, Intent it) { | |
| 256 | + Intent.WriteSlots w = intentService.extractWrite(userInput); | |
| 257 | + log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", | |
| 258 | + convId, w.entityType, w.record, w.field, w.newValue); | |
| 259 | + | |
| 260 | + String action = deriveWriteAction(it.intent, userInput); | |
| 261 | + String ent = pickEntity(w.entityType, it.danju); | |
| 262 | + | |
| 263 | + // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) | |
| 264 | + if ("update".equals(action)) { | |
| 265 | + java.util.List<String> need = new java.util.ArrayList<>(); | |
| 266 | + if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); | |
| 267 | + if (isBlank(w.newValue)) need.add("改成什么新值"); | |
| 268 | + if (!need.isEmpty()) { | |
| 269 | + clarifyWrite(emitter, "修改", w.record, need); | |
| 270 | + return; | |
| 271 | + } | |
| 272 | + } else if (isBlank(w.record)) { | |
| 273 | + clarifyWrite(emitter, actionVerb(action), "", | |
| 274 | + java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); | |
| 275 | + return; | |
| 276 | + } | |
| 277 | + | |
| 278 | + // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 | |
| 279 | + String result = agentFactory.proposeWriteTool(identity) | |
| 280 | + .proposeWrite(action, ent, w.record, w.field, w.newValue, null); | |
| 281 | + emitWriteResult(emitter, convId, result); | |
| 282 | + } | |
| 283 | + | |
| 284 | + /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ | |
| 285 | + private void emitWriteResult(SseEmitter emitter, String convId, String result) { | |
| 286 | + try { | |
| 287 | + JsonNode r = mapper.readTree(result); | |
| 288 | + String opId = r.path("opId").asText(null); | |
| 289 | + if (opId != null && !opId.isBlank()) { | |
| 290 | + ops.attachConversation(opId, convId); | |
| 291 | + Map<String, Object> card = new LinkedHashMap<>(); | |
| 292 | + card.put("type", "write_proposal"); | |
| 293 | + card.put("opId", opId); | |
| 294 | + card.put("summary", r.path("summary").asText("")); | |
| 295 | + sendEvent(emitter, card); | |
| 296 | + send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); | |
| 297 | + } else { | |
| 298 | + send(emitter, "token", r.path("error").asText("无法完成该操作。")); | |
| 299 | + } | |
| 300 | + } catch (Exception e) { | |
| 301 | + send(emitter, "error", "服务异常:" + e.getMessage()); | |
| 302 | + } | |
| 303 | + send(emitter, "done", ""); | |
| 304 | + emitter.complete(); | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** 实体类型:抽取到的太泛(单据/记录/数据/单)时用意图门的 danju 兜底。 */ | |
| 308 | + private static String pickEntity(String entityType, String danju) { | |
| 309 | + boolean generic = entityType == null || entityType.isBlank() | |
| 310 | + || entityType.equals("单据") || entityType.equals("记录") | |
| 311 | + || entityType.equals("数据") || entityType.equals("单"); | |
| 312 | + if (!generic) return entityType.trim(); | |
| 313 | + return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); | |
| 314 | + } | |
| 315 | + | |
| 316 | + /** 由意图 + 用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删)。 */ | |
| 317 | + private String deriveWriteAction(String intent, String text) { | |
| 318 | + String t = text == null ? "" : text; | |
| 319 | + if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; | |
| 320 | + if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; | |
| 321 | + if (t.contains("作废")) return "invalid"; | |
| 322 | + if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; | |
| 323 | + if (Intent.UPDATE.equals(intent)) return "update"; | |
| 324 | + if (Intent.EXAMINE.equals(intent)) return "examine"; | |
| 325 | + if (Intent.DELETE.equals(intent)) return "invalid"; // 业务单据"删除"默认=作废(安全、可复原) | |
| 326 | + return "update"; | |
| 327 | + } | |
| 328 | + | |
| 329 | + private static String actionVerb(String action) { | |
| 330 | + switch (action) { | |
| 331 | + case "invalid": return "作废"; | |
| 332 | + case "cancelInvalid": return "复原"; | |
| 333 | + case "examine": return "审核"; | |
| 334 | + case "cancelExamine": return "销审"; | |
| 335 | + case "delete": return "删除"; | |
| 336 | + default: return "操作"; | |
| 337 | + } | |
| 338 | + } | |
| 339 | + | |
| 340 | + private void clarifyWrite(SseEmitter emitter, String verb, String record, java.util.List<String> need) { | |
| 341 | + String who = isBlank(record) ? "" : ("(记录:" + record + ")"); | |
| 342 | + send(emitter, "token", "要" + verb + who + ",我还需要您补充:" + String.join("、", need) | |
| 343 | + + "。请一起告诉我,我再为你生成待确认的操作。"); | |
| 344 | + send(emitter, "done", ""); | |
| 345 | + emitter.complete(); | |
| 346 | + } | |
| 347 | + | |
| 348 | + private static boolean isBlank(String s) { | |
| 349 | + return s == null || s.isBlank(); | |
| 350 | + } | |
| 351 | + | |
| 352 | + private static String firstNonBlank(String a, String b) { | |
| 353 | + return (a != null && !a.isBlank()) ? a : b; | |
| 354 | + } | |
| 355 | + | |
| 112 | 356 | /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ |
| 113 | 357 | private AgentIdentity resolveIdentity(ChatReq req) { |
| 114 | 358 | try { |
| ... | ... | @@ -122,9 +366,7 @@ public class AgentChatController { |
| 122 | 366 | return authz.devIdentity(); |
| 123 | 367 | } |
| 124 | 368 | |
| 125 | - /** | |
| 126 | - * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。 | |
| 127 | - */ | |
| 369 | + /** 工具执行回调:清掉工具前旁白(reset);再按工具类型推对应卡片/控件事件。 */ | |
| 128 | 370 | private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { |
| 129 | 371 | send(emitter, "reset", ""); |
| 130 | 372 | try { |
| ... | ... | @@ -144,7 +386,6 @@ public class AgentChatController { |
| 144 | 386 | sendEvent(emitter, card); |
| 145 | 387 | } |
| 146 | 388 | } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { |
| 147 | - // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件 | |
| 148 | 389 | JsonNode r = mapper.readTree(te.result()); |
| 149 | 390 | String type = r.path("type").asText(""); |
| 150 | 391 | if ("question".equals(type) || "form_collect".equals(type)) { | ... | ... |
src/main/java/com/xly/web/OpController.java
| ... | ... | @@ -94,6 +94,9 @@ public class OpController { |
| 94 | 94 | } |
| 95 | 95 | } else if ("delete".equals(opType)) { |
| 96 | 96 | r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); |
| 97 | + } else if ("invalid".equals(opType)) { | |
| 98 | + boolean cancel = "cancel".equals(str(op.get("sNewValue"))); // cancel=复原/取消作废,toVoid=作废 | |
| 99 | + r = erp.invalidForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), cancel); | |
| 97 | 100 | } else if ("examine".equals(opType)) { |
| 98 | 101 | int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核 |
| 99 | 102 | r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag); | ... | ... |
src/main/resources/templates/chat.html
| ... | ... | @@ -870,7 +870,7 @@ |
| 870 | 870 | }); |
| 871 | 871 | if (parts.length === 0) { alert('请至少填写一个字段'); return; } |
| 872 | 872 | $(this).prop('disabled', true).text('已提交'); |
| 873 | - $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请据此用 proposeCreate 生成待确认的新增。'); | |
| 873 | + $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请调用 proposeWrite(action=create) 生成待确认的新增。'); | |
| 874 | 874 | sendMessage(); |
| 875 | 875 | }); |
| 876 | 876 | scrollToBottom(); | ... | ... |