Commit 8b927e3bba7ff627a623e6706c758ee2c930b905

Authored by zichun
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.
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();
... ...