diff --git a/src/main/java/com/xly/agent/Intent.java b/src/main/java/com/xly/agent/Intent.java new file mode 100644 index 0000000..642ace2 --- /dev/null +++ b/src/main/java/com/xly/agent/Intent.java @@ -0,0 +1,69 @@ +package com.xly.agent; + +import java.util.ArrayList; +import java.util.List; + +/** + * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 + * + *

由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 + * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**, + * 而不是让弱模型在 12 个工具里盲选——这是治理「理解错/选错工具」的核心。 + */ +public class Intent { + + /** 操作意图。 */ + public static final String QUERY = "查询"; + public static final String CREATE = "新增"; + public static final String UPDATE = "修改"; + public static final String DELETE = "删除"; + public static final String EXAMINE = "审核"; + public static final String CHAT = "闲聊"; + public static final String UNCLEAR = "不清楚"; + + public static class Entity { + public String value; + public String role; // 客户/产品/物料/供应商/数量/尺寸/日期/金额/其他/未知 + public Entity() {} + public Entity(String value, String role) { this.value = value; this.role = role; } + } + + /** 写操作(修改/删除/审核)的专用槽位:比通用 entities 更贴合 propose* 工具的入参。 */ + public static class WriteSlots { + public String entityType = ""; // 记录所属实体:客户/供应商/物料/产品/单据… + public String record = ""; // 要操作的记录名(公司名/物料名/单号) + public String field = ""; // 要修改的字段中文名(删除/审核留空) + public String newValue = ""; // 修改后的新值(删除/审核留空) + } + + public String intent = UNCLEAR; + public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 + public List entities = new ArrayList<>(); + public List missing = new ArrayList<>(); // 完成该意图还缺的关键信息 + + public boolean isWrite() { + return CREATE.equals(intent) || UPDATE.equals(intent) + || DELETE.equals(intent) || EXAMINE.equals(intent); + } + + /** 取某业务角色的第一个实体值(如「客户」「产品」),没有则返回 null。 */ + public String firstValueByRole(String role) { + for (Entity e : entities) { + if (e != null && role.equals(e.role) && e.value != null && !e.value.isBlank()) { + return e.value.trim(); + } + } + return null; + } + + /** 所有实体值拼接(用于给下游 agent 做 grounding 描述)。 */ + public String describeEntities() { + StringBuilder sb = new StringBuilder(); + for (Entity e : entities) { + if (e == null || e.value == null || e.value.isBlank()) continue; + if (sb.length() > 0) sb.append(","); + sb.append(e.value).append("(").append(e.role == null ? "未知" : e.role).append(")"); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/xly/agent/ToolScope.java b/src/main/java/com/xly/agent/ToolScope.java new file mode 100644 index 0000000..492eaf0 --- /dev/null +++ b/src/main/java/com/xly/agent/ToolScope.java @@ -0,0 +1,17 @@ +package com.xly.agent; + +/** + * 按意图收窄「本轮可见的工具集」(架构 §5 的 tool scoping)。 + * + *

依据:单次 ReAct 的选工具准确率随工具数量上升而骤降(业界经验:50 个工具 ~94% → 200 个 ~64%)。 + * 弱模型(qwen3:14b Q4)一次性面对 12 个语义重叠的工具时,频繁选错族(把「查询」当「新增」)。 + * 于是先用意图门判类,再**只暴露该意图相关的 3-5 个工具**给 ReAct 执行——大幅降低选错概率。 + */ +public enum ToolScope { + /** 读:findForms/kgSearch/readFormData/lookupRecord/queryData(+askUser/loadSkill)。 */ + READ, + /** 写:collectForm/proposeCreate/Update/Delete/Examine(+lookupRecord 定位、askUser/loadSkill)。 */ + WRITE, + /** 兜底:全部工具(意图门失败时用)。 */ + FULL +} diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index 4b5cf1e..00933dd 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -3,6 +3,7 @@ package com.xly.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.agent.ReActAgent; +import com.xly.agent.ToolScope; import com.xly.service.AuditService; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; @@ -54,8 +55,6 @@ public class AgentFactory { private final SkillTool skillTool; private final InteractionTool interactionTool; - private volatile String cachedSystemPrompt; - public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel, @Qualifier("sqlChatModel") OllamaChatModel sqlModel, RedisChatMemoryStore memoryStore, @@ -79,33 +78,43 @@ public class AgentFactory { this.interactionTool = interactionTool; } - /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */ - private String systemPrompt() { - String p = cachedSystemPrompt; - if (p == null) { - synchronized (this) { - if (cachedSystemPrompt == null) { - cachedSystemPrompt = systemPromptService.buildSystemPrompt(); - } - p = cachedSystemPrompt; - } - } - return p; + /** 兜底:全部工具(意图门失败时用)。 */ + public ReActAgent build(AgentIdentity identity) { + return build(identity, ToolScope.FULL); } - /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */ - public ReActAgent build(AgentIdentity identity) { - String systemPrompt = systemPrompt(); + /** + * 按意图范围组装 ReAct agent:只暴露该范围相关的工具 + 对应版本的 system prompt。 + * + *

收窄可见工具(3-5 个)显著提升弱模型的选工具准确率;{@code maxSequentialToolsInvocations} + * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 + */ + public ReActAgent build(AgentIdentity identity, ToolScope scope) { + String systemPrompt = systemPromptService.buildPrompt(scope); + + ErpReadTool readTool = new ErpReadTool(erp, jdbc, resolver, identity); + Object[] tools; + switch (scope) { + case READ: + tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, + new QueryTool(sqlModel, jdbc, audit, identity, resolver)}; + break; + case WRITE: + tools = new Object[]{skillTool, interactionTool, readTool, + new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), + new FormCollectTool(erp, jdbc, resolver, identity, mapper)}; + break; + default: // FULL + tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool, + new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), + new QueryTool(sqlModel, jdbc, audit, identity, resolver), + new FormCollectTool(erp, jdbc, resolver, identity, mapper)}; + } + return AiServices.builder(ReActAgent.class) .streamingChatModel(streamingModel) - .tools( - kgQueryTool, - skillTool, - interactionTool, - new ErpReadTool(erp, jdbc, resolver, identity), - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), - new QueryTool(sqlModel, jdbc, audit, identity), - new FormCollectTool(erp, jdbc, resolver, identity, mapper)) + .tools(tools) + .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(30) @@ -114,4 +123,14 @@ public class AgentFactory { .systemMessageProvider(memoryId -> systemPrompt) .build(); } + + /** 供确定性「新增」路径直接构建 collectForm 表单(不经 LLM 工具选择)。 */ + public FormCollectTool formCollectTool(AgentIdentity identity) { + return new FormCollectTool(erp, jdbc, resolver, identity, mapper); + } + + /** 供确定性「修改/作废/审核…」路径直接调 proposeWrite(不经 LLM 工具选择)。 */ + public ProposeWriteTool proposeWriteTool(AgentIdentity identity) { + return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver); + } } diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 7fb240f..502a0e8 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -194,6 +194,45 @@ public class ErpClient { } /** + * 作废 / 取消作废(复原)一条记录(ERP {@code /checkModel/updatebInvalid} → 存储过程 {@code Sp_Invalidation})。 + * {@code cancel=false} → 作废(handleType=toVoid);{@code cancel=true} → 取消作废/复原(handleType=cancel)。 + * 业务单据的“删除”应走这里(软作废,可复原),而非物理删。会话过期自动重登重试。 + */ + public JsonNode invalidForm(String authToken, String moduleId, String table, String billId, boolean cancel) { + JsonNode root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); + if (root.path("code").asInt() == -2 && canRelogin(authToken)) { + login(); + root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); + } + return root; + } + + private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) { + try { + String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + moduleId; + Map body = new LinkedHashMap<>(); + body.put("sId", List.of(billId)); // 后端 sId 期望 List + body.put("sTableName", table); + body.put("handleType", cancel ? "cancel" : "toVoid"); + body.put("sClientType", "PC"); + body.put("sComputeName", "AI-Agent"); + body.put("sIpAddress", "127.0.0.1"); + body.put("sLanguage", "chinese"); + String json = mapper.writeValueAsString(body); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(60)) + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return mapper.readTree(resp.body()); + } catch (Exception e) { + throw new RuntimeException("ERP 作废异常: " + e.getMessage(), e); + } + } + + /** * 委托 ERP 侧暂存执行器执行一条暂存写操作(架构 §10)。ERP 读共享库 {@code ai_op_queue} 行、以用户身份 * 执行并回写状态,返回 {@code {status, msg, billId}}。用户 token 必传(执行以用户身份进行)。 */ diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java new file mode 100644 index 0000000..ae82851 --- /dev/null +++ b/src/main/java/com/xly/service/IntentService.java @@ -0,0 +1,159 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xly.agent.Intent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。 + * + *

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

失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。 + */ +@Service +public class IntentService { + + private static final Logger log = LoggerFactory.getLogger(IntentService.class); + + private static final String SYSTEM = + "你是印刷/包装 ERP 的意图与实体抽取器。给你一句用户话,判断其操作意图" + + "(查询/新增/修改/删除/审核/闲聊/不清楚)、涉及的单据或实体类型,并抽取句中出现的实体及其业务角色" + + "(客户/产品/物料/供应商/数量/尺寸/日期/金额)。规则:" + + "①『报价X』『给X报价』里,若 X 是物品(如 纸盒/彩盒/画册/小册子)则 X 是**产品**,不是客户;" + + "只有明确出现公司/客户名(如『给苏州华为报价』)时才把它当**客户**。" + + "②对某个产品/规格问价——『多少钱』『什么价』『报个价』『报价』『价格是多少』——在印刷/包装行业" + + "**通常是要新建一张报价单**(价格由系统核价算出,系统里没有该定制活的现成价),intent=**新增**、单据=**报价**;" + + "只有明确查看/统计**已存在**的单据(给了单号,或说『查已有报价/列出报价单/上月报价额』)才是查询。" + + "③『有多少X/查一下X/X的电话是多少』这类问既有数据是**查询**,不是新增。" + + "④只说一个名词(如『报价』『纸盒』)而无动作时,intent=不清楚。" + + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。" + + "只输出 JSON,不要解释。"; + + private final OllamaJsonClient llm; + + public IntentService(OllamaJsonClient llm) { + this.llm = llm; + } + + /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */ + public Intent classify(String utterance) { + Intent out = new Intent(); + if (utterance == null || utterance.isBlank()) { + return out; + } + JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); + if (n == null) { + log.warn("intent classify fell back to UNCLEAR (model unavailable)"); + return out; + } + String intent = n.path("intent").asText(Intent.UNCLEAR); + out.intent = normalizeIntent(intent); + out.danju = n.path("danju").asText(""); + JsonNode es = n.path("entities"); + if (es.isArray()) { + for (JsonNode e : es) { + String v = e.path("value").asText(""); + String r = e.path("role").asText("未知"); + if (!v.isBlank()) { + out.entities.add(new Intent.Entity(v, r)); + } + } + } + JsonNode ms = n.path("missing"); + if (ms.isArray()) { + for (JsonNode m : ms) { + String v = m.asText(""); + if (!v.isBlank()) out.missing.add(v); + } + } + return out; + } + + private static final String WRITE_SYSTEM = + "你是 ERP 写操作的槽位抽取器。从用户话中抽取要操作的记录及改动:" + + "record=要操作的那条记录的名称(公司名/客户名/物料名/单号等);" + + "entityType=该记录属于哪类实体(客户/供应商/物料/产品/单据;拿不准就填 客户);" + + "field=要修改的字段中文名(如 电话/地址/简称/备注),删除或审核时留空;" + + "newValue=改成什么新值,删除或审核时留空。只输出 JSON,不要解释。"; + + /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ + public Intent.WriteSlots extractWrite(String utterance) { + Intent.WriteSlots w = new Intent.WriteSlots(); + if (utterance == null || utterance.isBlank()) { + return w; + } + JsonNode n = llm.completeJson(WRITE_SYSTEM, utterance.trim(), writeSchema()); + if (n == null) { + return w; + } + w.entityType = n.path("entityType").asText(""); + w.record = n.path("record").asText(""); + w.field = n.path("field").asText(""); + w.newValue = n.path("newValue").asText(""); + return w; + } + + private Map writeSchema() { + Map props = new LinkedHashMap<>(); + props.put("entityType", Map.of("type", "string")); + props.put("record", Map.of("type", "string")); + props.put("field", Map.of("type", "string")); + props.put("newValue", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", props); + schema.put("required", List.of("record")); + return schema; + } + + private static String normalizeIntent(String s) { + if (s == null) return Intent.UNCLEAR; + s = s.trim(); + switch (s) { + case Intent.QUERY: + case Intent.CREATE: + case Intent.UPDATE: + case Intent.DELETE: + case Intent.EXAMINE: + case Intent.CHAT: + case Intent.UNCLEAR: + return s; + default: + return Intent.UNCLEAR; + } + } + + private Map schema() { + Map entityProps = new LinkedHashMap<>(); + entityProps.put("value", Map.of("type", "string")); + entityProps.put("role", Map.of("type", "string", + "enum", List.of("客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知"))); + Map entityItem = new LinkedHashMap<>(); + entityItem.put("type", "object"); + entityItem.put("properties", entityProps); + entityItem.put("required", List.of("value", "role")); + + Map props = new LinkedHashMap<>(); + props.put("intent", Map.of("type", "string", + "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚"))); + props.put("danju", Map.of("type", "string")); + props.put("entities", Map.of("type", "array", "items", entityItem)); + props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); + + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", props); + schema.put("required", List.of("intent", "danju", "entities")); + return schema; + } +} diff --git a/src/main/java/com/xly/service/OllamaJsonClient.java b/src/main/java/com/xly/service/OllamaJsonClient.java new file mode 100644 index 0000000..4b9367b --- /dev/null +++ b/src/main/java/com/xly/service/OllamaJsonClient.java @@ -0,0 +1,80 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.util.OkHttpUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 直连 Ollama /api/chat 的**受约束 JSON**补全(constrained decoding)。 + * + *

为什么绕开 LangChain4j:Ollama 的 {@code format=} 会用 XGrammar 做语法约束解码, + * 保证输出**一定**是 schema 合法的 JSON(违反 schema 的 token 概率直接置 0)——这是根治「把产品名塞进客户字段」 + * 这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下意图/实体抽取 8/8 正确、~2-3s/次(think=false)。 + * + *

用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与 + * {@link SlotFillService}(按表单真实字段做受约束的槽位填充)。主对话/查询仍走 LangChain4j 工具循环。 + */ +@Service +public class OllamaJsonClient { + + private static final Logger log = LoggerFactory.getLogger(OllamaJsonClient.class); + + private final ObjectMapper mapper; + + @Value("${langchain4j.ollama.base-url}") + private String baseUrl; + + @Value("${langchain4j.ollama.chat-model-name}") + private String model; + + public OllamaJsonClient(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** + * 受约束 JSON 补全:think=false(快且稳定,实测无精度损失)、低温度、format=schema、非流式。 + * + * @param system 系统提示(角色 + 抽取规则) + * @param user 用户话 + * @param schema JSON Schema(Map 结构,直接序列化进 format) + * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) + */ + public JsonNode completeJson(String system, String user, Map schema) { + try { + Map body = new LinkedHashMap<>(); + body.put("model", model); + body.put("stream", false); + body.put("think", false); + body.put("format", schema); + body.put("messages", List.of( + Map.of("role", "system", "content", system), + Map.of("role", "user", "content", user))); + Map opts = new LinkedHashMap<>(); + opts.put("temperature", 0.1); + opts.put("top_p", 0.9); + opts.put("num_ctx", 8192); // 足够容纳字段清单/历史,避免长上下文时工具/JSON 退化 + body.put("options", opts); + + String json = mapper.writeValueAsString(body); + String resp = OkHttpUtil.getInstance(10, 120, 30).postJson(baseUrl + "/api/chat", json); + JsonNode root = mapper.readTree(resp); + String content = root.path("message").path("content").asText(""); + if (content.isBlank()) { + log.warn("ollama json completion: empty content"); + return null; + } + return mapper.readTree(content); + } catch (Exception e) { + log.warn("ollama json completion failed: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/xly/service/SlotFillService.java b/src/main/java/com/xly/service/SlotFillService.java new file mode 100644 index 0000000..bdd9377 --- /dev/null +++ b/src/main/java/com/xly/service/SlotFillService.java @@ -0,0 +1,200 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.Intent; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 新增单据的**确定性槽位映射**:把意图门已抽好的「带角色实体」+ 正则解析的尺寸/数量, + * 按表单真实字段落位,产出 collectForm 的预填 JSON。 + * + *

为什么不用 LLM 做这步:实测 qwen3:14b 能可靠区分「产品 vs 客户」(意图门已给出角色), + * 但对「长和宽50cm,高5cm」这类**尺寸拆分**始终出错(塞进备注/单价)。这类窄任务用 Java 正则 + + * 角色映射远比让模型受约束填槽可靠——把不确定的判断交给能确定处理的代码,是治『不智能』的关键手法之一。 + */ +@Service +public class SlotFillService { + + private final ObjectMapper mapper; + + public SlotFillService(ObjectMapper mapper) { + this.mapper = mapper; + } + + // 标注式尺寸:"长和宽50"、"高5cm"、"长:50" + private static final Pattern DIM_LABELED = + Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)"); + // 位置式尺寸:"50*30*5"、"50x30x5"、"50×30×5" + private static final Pattern DIM_POS = + Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); + + /** + * 依据意图门的角色实体 + 正则,确定性地构建新增表单的预填字段(中文字段名 -> 值)。 + * + * @param utterance 用户原话(用于解析尺寸) + * @param entity 单据/实体类型(如 报价 / 客户) + * @param fields {@code FormResolverService.businessFields}(含 label/fk/type) + * @param it 意图门结果(提供带角色的实体) + */ + public Map buildCreateFields(String utterance, String entity, + List> fields, Intent it) { + Map known = new LinkedHashMap<>(); + if (fields == null || fields.isEmpty()) { + return known; + } + // 干净字段名 -> 原始 label(供尺寸/数量按名定位) + Map cleanToLabel = new LinkedHashMap<>(); + for (Map f : fields) { + String label = str(f.get("label")); + if (label != null && !label.isBlank()) { + cleanToLabel.putIfAbsent(cleanLabel(label), label); + } + } + + String cust = it == null ? null : it.firstValueByRole("客户"); + String prod = it == null ? null : it.firstValueByRole("产品"); + String mat = it == null ? null : it.firstValueByRole("物料"); + String sup = it == null ? null : it.firstValueByRole("供应商"); + + // 1) 外键角色字段:客户/产品/物料/供应商 → 对应 FK 字段 + for (Map f : fields) { + String fk = str(f.get("fk")); + String label = str(f.get("label")); + if (fk == null || label == null) continue; + if ("elecustomer".equals(fk) && cust != null) known.putIfAbsent(label, cust); + else if ("eleproduct".equals(fk) && prod != null) known.putIfAbsent(label, prod); + else if ("elematerials".equals(fk) && mat != null) known.putIfAbsent(label, mat); + else if ("elesupplier".equals(fk) && sup != null) known.putIfAbsent(label, sup); + } + + // 2) 数量:意图门角色=数量 → 只留数字 + String qty = it == null ? null : it.firstValueByRole("数量"); + if (qty != null) { + String lbl = cleanToLabel.get("数量"); + if (lbl != null) { + String d = qty.replaceAll("[^0-9.]", ""); + if (!d.isEmpty()) known.putIfAbsent(lbl, d); + } + } + + // 3) 尺寸:正则从原话解析 长/宽/高 + Map dims = parseDimensions(utterance); + for (Map.Entry e : dims.entrySet()) { + String lbl = cleanToLabel.get(e.getKey()); // "长"/"宽"/"高" + if (lbl != null) known.putIfAbsent(lbl, e.getValue()); + } + + // 4) 名称字段:新增某实体本身(如"新增客户叫X")时,实体名要落到它的名称字段 + // (客户表本身没有 elecustomer 外键列,故不会被上面的 FK 环填上,需在此补)。 + String nameLabel = findNameField(fields); + if (nameLabel != null && !known.containsKey(nameLabel)) { + String selfRole = danjuToRole(entity); + String nameVal = (selfRole != null && it != null) ? it.firstValueByRole(selfRole) : null; + if (nameVal == null && cust == null && prod == null && mat == null && sup == null) { + nameVal = firstEntityValue(it); // 兜底:单据类型不含角色词时用首个实体 + } + if (nameVal != null) { + known.put(nameLabel, nameVal); + } + } + return known; + } + + /** 单据/实体类型 → 它自身的业务角色(新增该实体时,该角色的实体值即其名称)。 */ + private static String danjuToRole(String entity) { + if (entity == null) return null; + if (entity.contains("客户")) return "客户"; + if (entity.contains("供应商")) return "供应商"; + if (entity.contains("物料")) return "物料"; + if (entity.contains("产品")) return "产品"; + return null; + } + + /** 解析尺寸 → {长,宽,高} 的数字(cm/mm 单位忽略,只取数值)。 */ + public Map parseDimensions(String utterance) { + Map out = new LinkedHashMap<>(); + if (utterance == null || utterance.isBlank()) return out; + Matcher m = DIM_LABELED.matcher(utterance); + boolean any = false; + while (m.find()) { + String labels = m.group(1); + String val = m.group(2); + for (int i = 0; i < labels.length(); i++) { + char c = labels.charAt(i); + String key = dimKey(c); + if (key != null) { + out.put(key, val); + any = true; + } + } + } + if (!any) { + Matcher p = DIM_POS.matcher(utterance); + if (p.find()) { + out.put("长", p.group(1)); + out.put("宽", p.group(2)); + if (p.group(3) != null) out.put("高", p.group(3)); + } + } + return out; + } + + private static String dimKey(char c) { + switch (c) { + case '长': return "长"; + case '宽': return "宽"; + case '高': + case '厚': + case '深': return "高"; + default: return null; + } + } + + /** 名称字段:优先 label 以「名称」结尾且非外键的字段;否则第一个 label 含「名称」的。 */ + private static String findNameField(List> fields) { + for (Map f : fields) { + String label = str(f.get("label")); + String fk = str(f.get("fk")); + if (label != null && label.endsWith("名称") && (fk == null || fk.isBlank())) { + return label; + } + } + for (Map f : fields) { + String label = str(f.get("label")); + if (label != null && label.contains("名称")) return label; + } + return null; + } + + private static String firstEntityValue(Intent it) { + if (it == null) return null; + for (Intent.Entity e : it.entities) { + if (e != null && e.value != null && !e.value.isBlank()) return e.value.trim(); + } + return null; + } + + /** 序列化为 knownFieldsJson,供 {@code FormCollectTool.collectForm} 预填。 */ + public String toJson(Map known) { + try { + return mapper.writeValueAsString(known); + } catch (Exception e) { + return "{}"; + } + } + + private static String cleanLabel(String s) { + if (s == null) return ""; + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 5f051fa..e774001 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -1,5 +1,6 @@ package com.xly.service; +import com.xly.agent.ToolScope; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -7,12 +8,11 @@ import java.util.List; import java.util.Map; /** - * 构建单 agent 的 system prompt。 + * 构建 agent 的 system prompt——**按工具范围(ToolScope)分版**。 * - *

核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)与 - * Skill 摘要({@code ai_skill} 的 name+何时用)渲染进 system prompt,作为常驻的「路由地图 + 技能目录」—— - * 让单 agent 先判断问题属于哪个业务域/是否命中某 Skill,再决定调哪个工具。L1/Skill 摘要体量小且永远相关, - * 适合常驻;L2/L3 与 Skill 详情大而稀疏,走工具(KgSearch / load_skill)按需查。 + *

新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。 + * 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。 + * 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。 */ @Service public class SystemPromptService { @@ -20,59 +20,105 @@ public class SystemPromptService { private final JdbcTemplate jdbc; private final SkillService skills; + private volatile String readPrompt; + private volatile String writePrompt; + private volatile String fullPrompt; + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { this.jdbc = jdbc; this.skills = skills; } + private static final String HEADER = + "【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**," + + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" + + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; + + /** 兼容旧入口:默认给 FULL 版。 */ public String buildSystemPrompt() { - return """ - 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 + return buildPrompt(ToolScope.FULL); + } + + public String buildPrompt(ToolScope scope) { + switch (scope) { + case READ: + if (readPrompt == null) readPrompt = renderRead(); + return readPrompt; + case WRITE: + if (writePrompt == null) writePrompt = renderWrite(); + return writePrompt; + default: + if (fullPrompt == null) fullPrompt = renderFull(); + return fullPrompt; + } + } + + private String renderRead() { + return HEADER + """ + + 本轮任务是**查询 / 读取**数据(不做任何写操作)。 + + 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 + %s + 【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做: + %s + 【可用工具(只有这些)】 + - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 + - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。 + - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 + - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。 + - queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。 + - loadSkill(name)、askUser(question, options?)。 + + 【准则】 + 1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。 + 2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。 + 3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。 + """.formatted(renderDomainMap(), renderSkills()); + } + + private String renderWrite() { + return HEADER + """ - 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ - 帮助他们查询和操作 ERP 里的业务单据。 + 本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、 + 绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。 - 【业务域地图(L1 路由)】 - 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ - 可能涉及哪些单据,再决定怎么做: + 【可用工具(只有这些)】 + - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。 + - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作: + · create=新增(用 fieldsJson,通常在用户表单提交后) + · update=改某字段(用 recordKeyword+fieldChinese+newValue) + · invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废 + · examine=审核;cancelExamine=销审/反审核 + · delete=物理删除(明细行/极少用,业务单据别用) + 它自行定位主表与记录,无需先 findForms。 + - lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。 + - askUser(question, options?)、loadSkill(name)。 + + 【准则】 + 1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。 + 2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。 + 3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。 + """.formatted(); + } + + private String renderFull() { + // 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。 + return HEADER + """ + + 【业务域地图】 %s - 【可用技能 Skills】 - 下面是一些常见任务的技能。若用户需求命中某个技能的「何时用」,先调用 loadSkill(该技能名) 拿到详细步骤再照做: + 【可用技能】命中「何时用」时先 loadSkill(技能名): %s - 【可用工具】 - - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 - - kgSearch(keyword):查知识图谱——某表单的上下游流转、相邻单据、字段所在的表/列。想弄清「这张单从哪来、到哪去」或「某字段在哪张表」时用。 - - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 - - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ - 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 - - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ - 仅当 readFormData / lookupRecord 都答不了时才用。 - - askUser(question, options?):意图不明或需二选一时,向用户提一个带选项的小问题;问完即等用户回答。 - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐。\ - 把用户已说的信息作为 knownFieldsJson 预填(字段中文名->值,如 {"产品名称":"纸盒","数量":"1000","长(L)":"50"}),\ - 客户/产品/物料等会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,提交后你再用 proposeCreate。 - - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ - fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。\ - **仅当字段(尤其客户/产品)都由用户明确给出、无需猜测时才直接用它**;否则先 collectForm。 - - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段(写操作,只提议、暂不执行)。 - - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行)。删除不可恢复,慎用。 - - proposeExamine(entityKeyword, recordKeyword):**提议审核/过账**某张单据(写操作,只提议、暂不执行)。审核有业务后果,慎用。 - - loadSkill(name):加载某个业务技能的完整操作指南(见上方 Skills 清单)。 - 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ - (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ - (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 - - 【行为准则】 - 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 - 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 - 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete、审核用 proposeExamine。\ - 它们都**只生成待确认提议、不立即执行**;真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。 - 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ - 只有确实缺少关键参数(如不知道要查哪张单据)时才用 askUser 提问。 - 6. **新增报价**("报价X"/"给X报价"/"新增报价")**一律先 collectForm**,把用户说的产品/数量/尺寸作为 knownFieldsJson 预填,\ - 客户/产品让用户在下拉里选真实数据。**绝不要把产品名、盒型、规格猜成客户名称**——"报价纸盒"里"纸盒"是产品不是客户;\ - 客户未知就交给表单让用户选,不要自己编、也不要因为找不到就提议新建客户。 + 【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData; + 写(人在环,仅提议):collectForm/proposeCreate/proposeUpdate/proposeDelete/proposeExamine; + 以及 askUser/loadSkill。 + + 【准则】 + 1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。 + 2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm。 + 3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。 + 4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。 """.formatted(renderDomainMap(), renderSkills()); } @@ -88,7 +134,6 @@ public class SystemPromptService { "SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " + "FROM viw_kg_domain ORDER BY iForms DESC"); } catch (Exception e) { - // KG 视图缺失时不应阻断启动——降级为空域图,agent 仍可运行。 org.slf4j.LoggerFactory.getLogger(SystemPromptService.class) .warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage()); return "(业务域地图暂不可用)\n"; diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index fd95160..81d99ff 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -43,14 +43,96 @@ public class ProposeWriteTool { this.resolver = resolver; } - @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" - + "必须等用户在对话内点【确认】后才真正修改。用于「把某个客户/物料的某字段改成X」这类需求。" - + "直接给出实体类型(如 客户)即可,本工具会自行定位主表,无需先 findForms。") - public String proposeUpdate( - @P("实体/单据类型关键词,如 客户 / 物料 / 供应商") String entityKeyword, - @P("要修改的那条记录的名称关键词(如某个客户名)") String recordKeyword, - @P("要修改的字段中文名(如 简称 / 备注 / 联系电话)") String fieldChinese, - @P("修改后的新值") String newValue) { + @Tool("提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。" + + "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);" + + "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。" + + "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;" + + "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。") + public String proposeWrite( + @P("动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete") String action, + @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword, + @P(value = "记录名称或单号关键词(create 不需要)", required = false) String recordKeyword, + @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese, + @P(value = "新值(仅 update)", required = false) String newValue, + @P(value = "字段 JSON 中文名->值(仅 create)", required = false) String fieldsJson) { + if (isBlank(action)) { + return err("缺少 action。"); + } + switch (action.trim().toLowerCase()) { + case "create": return doCreate(entityKeyword, fieldsJson); + case "update": return doUpdate(entityKeyword, recordKeyword, fieldChinese, newValue); + case "invalid": return doInvalidate(entityKeyword, recordKeyword, false); + case "cancelinvalid": return doInvalidate(entityKeyword, recordKeyword, true); + case "examine": return doExamineOp(entityKeyword, recordKeyword, 1); + case "cancelexamine": return doExamineOp(entityKeyword, recordKeyword, 0); + case "delete": return doDelete(entityKeyword, recordKeyword); + default: + return err("未知 action:" + action + "(支持 create/update/invalid/cancelInvalid/examine/cancelExamine/delete)"); + } + } + + /** 作废(cancel=false)/复原·取消作废(cancel=true)一条记录 → ERP updatebInvalid(Sp_Invalidation)。业务单据的“删除”走这里。 */ + private String doInvalidate(String entityKeyword, String recordKeyword, boolean cancel) { + String verb = cancel ? "复原" : "作废"; + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { + return err("缺少实体类型或记录名称。"); + } + Map form = resolveForm(entityKeyword.trim()); + if (form == null) { + return err("找不到「" + entityKeyword + "」对应的可操作主表。"); + } + String formId = str(form.get("sFormId")); + String moduleId = str(form.get("sModuleId")); + String table = str(form.get("sDataSource")); + if (!identity.canAccessModule(moduleId)) { + return err("你没有操作「" + entityKeyword + "」的权限。"); + } + String nameField = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + + "ORDER BY iFormUses DESC LIMIT 1", table); + JsonNode root; + try { + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + return err("定位记录失败:" + e.getMessage()); + } + if (root.path("code").asInt(0) < 0) { + return err("定位记录失败:" + root.path("msg").asText("未知错误")); + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + return err("没有找到名称含「" + recordKeyword + "」的记录。"); + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); + } + return err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); + } + JsonNode rec = data.get(0); + String billId = rec.path("sId").asText(null); + if (isBlank(billId)) { + return err("定位到的记录缺少主键 sId,无法" + verb + "。"); + } + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); + String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; + // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid + String opId = ops.createDraft(identity.userId(), "invalid", formId, moduleId, table, billId, + null, null, null, cancel ? "cancel" : "toVoid", description); + + Map out = new LinkedHashMap<>(); + out.put("opId", opId); + out.put("summary", description); + out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。"); + return toJson(out); + } + + /** 修改某条记录的某个字段。见 {@link #proposeWrite}。 */ + private String doUpdate(String entityKeyword, String recordKeyword, String fieldChinese, String newValue) { if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) { return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); @@ -130,11 +212,8 @@ public class ProposeWriteTool { return toJson(out); } - @Tool("提议**删除**某条现有记录(写操作)。本工具只提议并暂存、绝不立即执行——必须等用户点【确认】后才真正删除。" - + "删除不可恢复,请慎用。直接给出实体类型与记录名即可。") - public String proposeDelete( - @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, - @P("要删除的记录的名称关键词") String recordKeyword) { + /** 物理删除一条记录(罕用;业务单据请用 doInvalidate 作废)。见 {@link #proposeWrite}。 */ + private String doDelete(String entityKeyword, String recordKeyword) { if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少实体类型或记录名称。"); @@ -192,11 +271,8 @@ public class ProposeWriteTool { return toJson(out); } - @Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。" - + "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。") - public String proposeCreate( - @P("实体类型,如 客户 / 物料") String entityKeyword, - @P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) { + /** 新增一条记录(报价走多表 proposeQuote)。见 {@link #proposeWrite}。 */ + private String doCreate(String entityKeyword, String fieldsJson) { if (isBlank(entityKeyword)) { return err("缺少实体类型。"); @@ -304,11 +380,8 @@ public class ProposeWriteTool { return toJson(out); } - @Tool("提议**审核/过账**某条单据(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正审核。" - + "用于「审核 / 过账某张单据」这类需求;审核有业务后果,请谨慎。给出单据类型与单号/名称即可。") - public String proposeExamine( - @P("单据类型,如 销售订单 / 采购订单 / 报价") String entityKeyword, - @P("要审核的单据编号或名称关键词") String recordKeyword) { + /** 审核(iFlag=1)/反审核·销审(iFlag=0)一条单据 → ERP doExamine。见 {@link #proposeWrite}。 */ + private String doExamineOp(String entityKeyword, String recordKeyword, int iFlag) { if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少单据类型或单号/名称。"); @@ -355,15 +428,16 @@ public class ProposeWriteTool { return err("定位到的单据缺少主键 sId,无法审核。"); } String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); - String description = "审核【" + recordName + "】(" + entityKeyword + ")"; - // examine:sNewValue 存 iFlag(1=审核);执行走 ERP doExamine(存储过程驱动) + String verb = iFlag == 1 ? "审核" : "反审核"; + String description = verb + "【" + recordName + "】(" + entityKeyword + ")"; + // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId, - null, null, null, "1", description); + null, null, null, String.valueOf(iFlag), description); Map out = new LinkedHashMap<>(); out.put("opId", opId); out.put("summary", description); - out.put("message", "已为你生成一条待确认的审核,请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); + out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); return toJson(out); } diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java index 7f06ed9..bd0e04f 100644 --- a/src/main/java/com/xly/tool/QueryTool.java +++ b/src/main/java/com/xly/tool/QueryTool.java @@ -2,6 +2,7 @@ package com.xly.tool; import com.xly.agent.AgentIdentity; import com.xly.service.AuditService; +import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.model.ollama.OllamaChatModel; @@ -18,10 +19,12 @@ import net.sf.jsqlparser.util.TablesNamesFinder; import org.springframework.jdbc.core.JdbcTemplate; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; /** * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 @@ -37,12 +40,15 @@ public class QueryTool { private final JdbcTemplate jdbc; private final AuditService audit; private final AgentIdentity identity; + private final FormResolverService resolver; - public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity) { + public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, + AgentIdentity identity, FormResolverService resolver) { this.sqlModel = sqlModel; this.jdbc = jdbc; this.audit = audit; this.identity = identity; + this.resolver = resolver; } @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" @@ -98,6 +104,8 @@ public class QueryTool { 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表; 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据); **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; + **给用户看的是名称不是内部ID**:若按某实体(产品/客户/物料/供应商)分组或排名,必须 JOIN 该实体主表、 + 在结果里返回它的**名称列**(如 eleproduct.sProductName、elecustomer.sCustomerName),不要只返回 *Id 列; 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 问题:%s%s """.formatted(hint, brandHint(), question, repair); @@ -313,6 +321,60 @@ public class QueryTool { return sql; } + // 裸主键ID(业务 sId 通常为 15+ 位数字串)。仅对严格匹配的长数字尝试解析,普通数字(数量/金额)不受影响。 + private static final Pattern LONG_ID = Pattern.compile("\\d{15,}"); + private static volatile Map NAME_TABLES; // 主表 -> 名称字段(静态缓存) + private final Map idNameCache = new ConcurrentHashMap<>(); // 本次请求内的 id->名称 缓存 + + /** 被外键引用的主表集合及其名称字段(客户/产品/物料/供应商…)。用于把裸ID回解析成名称。 */ + private Map nameTables() { + Map c = NAME_TABLES; + if (c != null) { + return c; + } + Map m = new LinkedHashMap<>(); + try { + for (Map r : jdbc.queryForList( + "SELECT DISTINCT sFkTable FROM viw_kg_field_dict WHERE IFNULL(sFkTable,'')<>'' " + + "AND sFkTable NOT LIKE 'viw%'")) { + String t = String.valueOf(r.get("sFkTable")); + if (t == null || t.isBlank()) continue; + String nf = resolver.resolveNameField(t); + if (nf != null && !nf.isBlank()) { + m.put(t, nf); + } + } + } catch (Exception ignore) { + } + NAME_TABLES = m; + return m; + } + + /** 长ID → 名称(在被引用主表里按 sId 命中即返回;找不到返回 null,保留原ID)。 */ + private String resolveId(String val) { + String cached = idNameCache.get(val); + if (cached != null) { + return cached.isEmpty() ? null : cached; + } + for (Map.Entry e : nameTables().entrySet()) { + try { + List> r = jdbc.queryForList( + "SELECT `" + e.getValue() + "` nm FROM `" + e.getKey() + "` WHERE sId=? LIMIT 1", val); + if (!r.isEmpty()) { + Object nm = r.get(0).get("nm"); + if (nm != null && !String.valueOf(nm).isBlank()) { + String s = String.valueOf(nm); + idNameCache.put(val, s); + return s; + } + } + } catch (Exception ignore) { + } + } + idNameCache.put(val, ""); + return null; + } + private String formatRows(List> rows) { if (rows.isEmpty()) { return "查询完成,没有匹配的数据。"; @@ -332,6 +394,11 @@ public class QueryTool { for (String c : cols) { Object v = r.get(c); String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); + // 把裸的长ID(如产品/客户 sId)就地解析成名称,给用户看名字不是ID + if (LONG_ID.matcher(s).matches()) { + String nm = resolveId(s); + if (nm != null) s = nm; + } if (s.length() > 30) { s = s.substring(0, 30) + "…"; } diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index c9e6f81..0e703fc 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -3,11 +3,17 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; +import com.xly.agent.Intent; import com.xly.agent.ReActAgent; +import com.xly.agent.ToolScope; import com.xly.config.AgentFactory; import com.xly.service.AuthzService; import com.xly.service.ConversationService; +import com.xly.service.FormResolverService; +import com.xly.service.IntentService; import com.xly.service.OpService; +import com.xly.service.SlotFillService; +import com.xly.tool.FormCollectTool; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.tool.ToolExecution; import org.slf4j.Logger; @@ -21,6 +27,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -29,35 +36,51 @@ import java.util.concurrent.Executors; /** * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * - *

帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、 - * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。 - * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 + *

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

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

**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份, - * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。 + *

帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 + * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 */ @RestController @RequestMapping("/api/agent") public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); - private static final Set WRITE_TOOLS = - Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine"); + private static final Set WRITE_TOOLS = Set.of("proposeWrite"); + /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ + private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; private final AgentFactory agentFactory; private final AuthzService authz; private final ObjectMapper mapper; private final ConversationService conversations; private final OpService ops; + private final IntentService intentService; + private final SlotFillService slotFill; + private final FormResolverService resolver; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, - ConversationService conversations, OpService ops) { + ConversationService conversations, OpService ops, + IntentService intentService, SlotFillService slotFill, + FormResolverService resolver) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; + this.intentService = intentService; + this.slotFill = slotFill; + this.resolver = resolver; } public static class ChatReq { @@ -81,27 +104,13 @@ public class AgentChatController { : ((req.userid == null ? "anon" : req.userid) + ":default"); conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); - final AgentIdentity identity = resolveIdentity(req); - final ReActAgent agent = agentFactory.build(identity); exec.submit(() -> { try { - TokenStream ts = agent.chat(convId, userInput); - ts.onPartialResponse(token -> send(emitter, "token", token)) - .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) - .onCompleteResponse(resp -> { - send(emitter, "done", ""); - emitter.complete(); - }) - .onError(err -> { - log.warn("agent chat stream error (conv={})", convId, err); - send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); - emitter.complete(); - }) - .start(); + route(emitter, convId, identity, userInput); } catch (Exception e) { - log.error("agent invoke failed (conv={})", convId, e); + log.error("agent route failed (conv={})", convId, e); send(emitter, "error", "服务异常:" + e.getMessage()); emitter.complete(); } @@ -109,6 +118,241 @@ public class AgentChatController { return emitter; } + /** 意图门 + 确定性路由。 */ + private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { + // 0) 表单提交 → 直接走写(执行 proposeCreate),不再重新分类。 + if (userInput.contains(FORM_SUBMIT_MARK)) { + runAgent(emitter, convId, identity, ToolScope.WRITE, userInput); + return; + } + // 1) 意图门(失败时返回 UNCLEAR,走兜底)。 + Intent it = intentService.classify(userInput); + log.info("intent(conv={}): {} / {} / entities={} / missing={}", + convId, it.intent, it.danju, it.describeEntities(), it.missing); + + switch (it.intent) { + case Intent.CREATE: + if (handleCreate(emitter, convId, identity, userInput, it)) { + return; + } + // 无法确定性建表单 → 交给写 agent 处理(可能需要它先问清单据类型)。 + runAgent(emitter, convId, identity, ToolScope.WRITE, ground(userInput, it)); + return; + case Intent.UPDATE: + case Intent.DELETE: + case Intent.EXAMINE: + handleWrite(emitter, convId, identity, userInput, it); + return; + case Intent.QUERY: + runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it)); + return; + case Intent.CHAT: + runAgent(emitter, convId, identity, ToolScope.READ, userInput); + return; + case Intent.UNCLEAR: + default: + // 分类不确定/失败:给全部工具、原文交给 agent,尽量不丢能力。 + runAgent(emitter, convId, identity, ToolScope.FULL, userInput); + } + } + + /** + * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, + * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 + */ + private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity, + String userInput, Intent it) { + String entity = it.danju == null ? "" : it.danju.trim(); + if (entity.isEmpty()) { + return false; + } + Map form = resolver.resolveMasterForm(entity); + if (form == null) { + return false; + } + String table = String.valueOf(form.get("sDataSource")); + // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。 + List> fields = resolver.businessFields(table, 40); + Map known = slotFill.buildCreateFields(userInput, entity, fields, it); + + FormCollectTool fct = agentFactory.formCollectTool(identity); + String payload = fct.collectForm(entity, slotFill.toJson(known)); + try { + JsonNode r = mapper.readTree(payload); + if ("form_collect".equals(r.path("type").asText(""))) { + sendEvent(emitter, mapper.convertValue(r, Map.class)); + send(emitter, "token", "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"); + send(emitter, "done", ""); + emitter.complete(); + return true; + } + // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。 + String err = r.path("error").asText(""); + if (!err.isBlank()) { + send(emitter, "token", err); + send(emitter, "done", ""); + emitter.complete(); + return true; + } + } catch (Exception e) { + log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage()); + } + return false; + } + + /** 运行某一范围的 ReAct agent,把流式回调转成 SSE。 */ + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, + ToolScope scope, String text) { + try { + ReActAgent agent = agentFactory.build(identity, scope); + TokenStream ts = agent.chat(convId, text); + ts.onPartialResponse(token -> send(emitter, "token", token)) + .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) + .onCompleteResponse(resp -> { + send(emitter, "done", ""); + emitter.complete(); + }) + .onError(err -> { + log.warn("agent chat stream error (conv={})", convId, err); + send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); + emitter.complete(); + }) + .start(); + } catch (Exception e) { + log.error("agent invoke failed (conv={})", convId, e); + send(emitter, "error", "服务异常:" + e.getMessage()); + emitter.complete(); + } + } + + /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ + private String ground(String userInput, Intent it) { + StringBuilder g = new StringBuilder(userInput); + g.append("\n\n(意图分析,仅供参考:意图=").append(it.intent); + if (it.danju != null && !it.danju.isBlank()) { + g.append(",单据/实体类型=").append(it.danju); + } + // 用 角色=值 的形式,避免下游把"值(角色)"整体误当作参数值 + StringBuilder pairs = new StringBuilder(); + for (Intent.Entity e : it.entities) { + if (e == null || e.value == null || e.value.isBlank()) continue; + if (pairs.length() > 0) pairs.append(","); + pairs.append(e.role == null ? "其他" : e.role).append("=").append(e.value); + } + if (pairs.length() > 0) { + g.append(",识别到 ").append(pairs); + } + g.append("。调用工具时请用上面的**值**作为参数(不要把角色名或括号写进参数),实体角色不要弄错。)"); + return g.toString(); + } + + /** + * 写操作(修改/删除/审核)处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), + * 齐了就带干净 grounding 交给写 agent 直接 propose*(它会自定位主表/记录), + * 缺了就**确定性问一次**并停下——两头都不进失控循环。 + */ + private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, + String userInput, Intent it) { + Intent.WriteSlots w = intentService.extractWrite(userInput); + log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", + convId, w.entityType, w.record, w.field, w.newValue); + + String action = deriveWriteAction(it.intent, userInput); + String ent = pickEntity(w.entityType, it.danju); + + // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) + if ("update".equals(action)) { + java.util.List need = new java.util.ArrayList<>(); + if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); + if (isBlank(w.newValue)) need.add("改成什么新值"); + if (!need.isEmpty()) { + clarifyWrite(emitter, "修改", w.record, need); + return; + } + } else if (isBlank(w.record)) { + clarifyWrite(emitter, actionVerb(action), "", + java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); + return; + } + + // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 + String result = agentFactory.proposeWriteTool(identity) + .proposeWrite(action, ent, w.record, w.field, w.newValue, null); + emitWriteResult(emitter, convId, result); + } + + /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ + private void emitWriteResult(SseEmitter emitter, String convId, String result) { + try { + JsonNode r = mapper.readTree(result); + String opId = r.path("opId").asText(null); + if (opId != null && !opId.isBlank()) { + ops.attachConversation(opId, convId); + Map card = new LinkedHashMap<>(); + card.put("type", "write_proposal"); + card.put("opId", opId); + card.put("summary", r.path("summary").asText("")); + sendEvent(emitter, card); + send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); + } else { + send(emitter, "token", r.path("error").asText("无法完成该操作。")); + } + } catch (Exception e) { + send(emitter, "error", "服务异常:" + e.getMessage()); + } + send(emitter, "done", ""); + emitter.complete(); + } + + /** 实体类型:抽取到的太泛(单据/记录/数据/单)时用意图门的 danju 兜底。 */ + private static String pickEntity(String entityType, String danju) { + boolean generic = entityType == null || entityType.isBlank() + || entityType.equals("单据") || entityType.equals("记录") + || entityType.equals("数据") || entityType.equals("单"); + if (!generic) return entityType.trim(); + return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); + } + + /** 由意图 + 用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删)。 */ + private String deriveWriteAction(String intent, String text) { + String t = text == null ? "" : text; + if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; + if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; + if (t.contains("作废")) return "invalid"; + if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; + if (Intent.UPDATE.equals(intent)) return "update"; + if (Intent.EXAMINE.equals(intent)) return "examine"; + if (Intent.DELETE.equals(intent)) return "invalid"; // 业务单据"删除"默认=作废(安全、可复原) + return "update"; + } + + private static String actionVerb(String action) { + switch (action) { + case "invalid": return "作废"; + case "cancelInvalid": return "复原"; + case "examine": return "审核"; + case "cancelExamine": return "销审"; + case "delete": return "删除"; + default: return "操作"; + } + } + + private void clarifyWrite(SseEmitter emitter, String verb, String record, java.util.List need) { + String who = isBlank(record) ? "" : ("(记录:" + record + ")"); + send(emitter, "token", "要" + verb + who + ",我还需要您补充:" + String.join("、", need) + + "。请一起告诉我,我再为你生成待确认的操作。"); + send(emitter, "done", ""); + emitter.complete(); + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String firstNonBlank(String a, String b) { + return (a != null && !a.isBlank()) ? a : b; + } + /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ private AgentIdentity resolveIdentity(ChatReq req) { try { @@ -122,9 +366,7 @@ public class AgentChatController { return authz.devIdentity(); } - /** - * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。 - */ + /** 工具执行回调:清掉工具前旁白(reset);再按工具类型推对应卡片/控件事件。 */ private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { send(emitter, "reset", ""); try { @@ -144,7 +386,6 @@ public class AgentChatController { sendEvent(emitter, card); } } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { - // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件 JsonNode r = mapper.readTree(te.result()); String type = r.path("type").asText(""); if ("question".equals(type) || "form_collect".equals(type)) { diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index 4cd460c..b44378c 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -94,6 +94,9 @@ public class OpController { } } else if ("delete".equals(opType)) { r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); + } else if ("invalid".equals(opType)) { + boolean cancel = "cancel".equals(str(op.get("sNewValue"))); // cancel=复原/取消作废,toVoid=作废 + r = erp.invalidForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), cancel); } else if ("examine".equals(opType)) { int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核 r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag); diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 701c9e6..c7a8e9d 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -870,7 +870,7 @@ }); if (parts.length === 0) { alert('请至少填写一个字段'); return; } $(this).prop('disabled', true).text('已提交'); - $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请据此用 proposeCreate 生成待确认的新增。'); + $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请调用 proposeWrite(action=create) 生成待确认的新增。'); sendMessage(); }); scrollToBottom();