Commit 783cef5174e63fc24fba1d7fc5fd6ca5b6d3d8cb
1 parent
42a40f61
feat: create write op (proposeCreate) + Query self-repair + LLM tracing hook + a…
…i_op_queue staging cols + ai_skill registry - proposeCreate tool -> ai_op_queue draft(payload) -> confirm executes addBusinessData - ai_op_queue: add sPayload/sSourceRef/bAutoExecute/sResultBillId/sErrorMsg/tExecutedDate/tExpireAt (create/examine/auto-flow ready) - ai_skill registry table + seed playbooks (Skills §6) - QueryTool: self-repair retry (feed SQL error back to model, up to 3x) - TracingChatModelListener: per-call LLM latency/token/error trace (Langfuse hook point)
Showing
14 changed files
with
455 additions
and
34 deletions
mvnw
100644 → 100755
sql/ai_op_queue.sql
| 1 | 1 | -- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 |
| 2 | 2 | -- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 |
| 3 | --- v1 覆盖 update(改现有记录的字段);create/delete/examine 后续扩展。 | |
| 3 | +-- 覆盖 update / create / delete / examine / generate(sOpType 区分)。 | |
| 4 | +-- update/delete/examine:用 sTargetBillId 指向目标记录;改字段用 sField/sNewValue。 | |
| 5 | +-- create/generate:整表 payload 存 sPayload(列->值 JSON);generate 的源引用存 sSourceRef。 | |
| 6 | +-- bAutoExecute=1(如报价)→ ERP 确认后自动执行并回 sResultBillId;=0 → 预填表单让用户手动提交。 | |
| 4 | 7 | CREATE TABLE IF NOT EXISTS ai_op_queue ( |
| 5 | - sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token | |
| 8 | + sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token | |
| 6 | 9 | sUserId varchar(64), |
| 7 | 10 | sConversationId varchar(96), |
| 8 | 11 | sBrandsId varchar(32), |
| 9 | 12 | sSubsidiaryId varchar(32), |
| 10 | - sOpType varchar(16), -- update | create | delete | examine | |
| 13 | + sOpType varchar(16), -- update | create | delete | examine | generate | |
| 11 | 14 | sTargetFormId varchar(64), |
| 12 | 15 | sTargetModuleId varchar(64), |
| 13 | 16 | sTargetTable varchar(64), |
| 14 | - sTargetBillId varchar(64), -- 目标记录 sId(update/delete 用) | |
| 17 | + sTargetBillId varchar(64), -- 目标记录 sId(update/delete/examine 用) | |
| 15 | 18 | sField varchar(64), -- 目标列(技术名) |
| 16 | 19 | sFieldLabel varchar(128), -- 目标列中文名 |
| 17 | 20 | sOldValue varchar(500), |
| 18 | 21 | sNewValue varchar(500), |
| 22 | + sPayload text, -- create/update 的列->值 JSON(整表写入) | |
| 23 | + sSourceRef text, -- generate:源表单 + 选中明细 sId 集合 JSON | |
| 24 | + bAutoExecute tinyint NOT NULL DEFAULT 0, -- 1=ERP 确认后自动执行;0=预填表单等用户提交 | |
| 19 | 25 | sDescription varchar(500), -- 人类可读的改动描述 |
| 20 | - sStatus varchar(16), -- draft | confirmed | executed | failed | cancelled | |
| 26 | + sStatus varchar(16), -- draft | confirmed | executing | executed | failed | cancelled | expired | |
| 27 | + sResultBillId varchar(64), -- 执行后生成/影响的单据 id | |
| 21 | 28 | sResultMsg varchar(500), |
| 29 | + sErrorMsg varchar(1000), | |
| 22 | 30 | tCreateDate datetime, |
| 23 | 31 | tConfirmDate datetime, |
| 32 | + tExecutedDate datetime, | |
| 33 | + tExpireAt datetime, | |
| 24 | 34 | KEY idx_conv (sConversationId, sStatus) |
| 25 | 35 | ); | ... | ... |
sql/ai_skill.sql
0 → 100644
| 1 | +-- ai_skill:Skill 注册表(架构 §6)。 | |
| 2 | +-- Skill = 针对重复任务的 playbook(如 新建报价 / 月度对账 / 库存盘点),用工具但不是工具。 | |
| 3 | +-- 渐进披露:name + 何时用(sWhenToUse) 进 system prompt(便宜、常驻);load_skill(name) 工具 | |
| 4 | +-- 按需注入完整指令(sInstructions)。sSuggested = 建议用到的工具/表单(逗号分隔,仅提示)。 | |
| 5 | +CREATE TABLE IF NOT EXISTS ai_skill ( | |
| 6 | + sId varchar(64) NOT NULL PRIMARY KEY, | |
| 7 | + sName varchar(64) NOT NULL, -- Skill 名(load_skill 的入参) | |
| 8 | + sWhenToUse varchar(255) NOT NULL, -- 何时用(一句话,进 system prompt) | |
| 9 | + sInstructions text NOT NULL, -- 详细 playbook(load_skill 时注入) | |
| 10 | + sSuggested varchar(255) NULL, -- 建议工具/表单(逗号分隔) | |
| 11 | + bEnabled tinyint NOT NULL DEFAULT 1, | |
| 12 | + iOrder int NOT NULL DEFAULT 100, | |
| 13 | + tCreateDate datetime NULL | |
| 14 | +); | |
| 15 | + | |
| 16 | +INSERT INTO ai_skill (sId,sName,sWhenToUse,sInstructions,sSuggested,bEnabled,iOrder,tCreateDate) VALUES | |
| 17 | +('skill-new-quote','新建报价','用户要为某客户新建/录入一张报价单时', | |
| 18 | + '新建报价的标准流程:\n1) 先用 findForms("报价") 定位报价主表单,拿到 formId/moduleId。\n2) 报价字段较多(客户、币种、含税、明细行等),用 collectForm(该 formId) 弹出表单让用户逐项填写,而不是逐个追问。\n3) 用户填完后,用 proposeCreate 生成待确认的新增提议;务必让用户在对话内点【确认】后才真正落单。\n4) 报价属于自动执行类写入(bAutoExecute),确认后 ERP 会自动生成正式报价单并回链接。', | |
| 19 | + 'findForms, collectForm, proposeCreate',1,10,NOW()), | |
| 20 | +('skill-monthly-ar','月度对账/超期应收催收','用户要做应收对账、查超期未收款、催收时', | |
| 21 | + '应收对账/催收流程:\n1) 用 queryData 或 findForms("应收") 找到应收台账/收款单据。\n2) 按客户汇总未收金额,用 queryData 做“按客户分组 SUM(未收金额) 并按账龄排序”。\n3) 列出超期(如超过约定账期)客户与金额,提示用户重点催收。\n4) 只读分析,不做任何写操作。', | |
| 22 | + 'queryData, findForms, readFormData',1,20,NOW()), | |
| 23 | +('skill-inventory-check','库存查询/盘点','用户要查某物料/产品的库存、库存台账、进出明细时', | |
| 24 | + '库存查询流程:\n1) 物料库存看 elematerialsstock 相关表单,产品库存看 productstock。\n2) 用 lookupRecord(物料/产品, 名称) 查单个;用 readFormData 看列表;用 queryData 做汇总。\n3) 严禁传 bUpdate 之类写参数(只读)。\n4) 需要出入库明细时找 *_inoutdetail 视图。', | |
| 25 | + 'lookupRecord, readFormData, queryData',1,30,NOW()); | ... | ... |
sql/viw_ai_useful_forms.sql
0 → 100644
| 1 | +-- ============================================================ | |
| 2 | +-- 视图:viw_ai_useful_forms —— ERP「有用业务表单」目录 | |
| 3 | +-- 合并 gdsmodule(菜单窗体) + gdsconfigformmaster(表单配置) | |
| 4 | +-- 过滤原则:保召回优先,只剔除"必然无用"的项,再用字段让调用方自行收窄 | |
| 5 | +-- 剔除:失效 / 系统配置源(gds*/sys*) / BI报表 / 首页 / 外链 / 弹窗 / 系统配置窗体 | |
| 6 | +-- 保留:有业务数据源(表/视图/过程)的表单 | |
| 7 | +-- 收窄开关(作为字段暴露,不写死在过滤里,避免误删"明细/查询"类窗体): | |
| 8 | +-- bVisibleMenu = 1 → 主菜单可见的窗体(约370张,精度高) | |
| 9 | +-- bIsGrid = 1 → 列表/表格型窗体(适合出数据列表) | |
| 10 | +-- 调接口用法:POST /business/getBusinessDataByFormcustomId/{sFormId}?sModelsId={sModuleId} | |
| 11 | +-- ============================================================ | |
| 12 | +CREATE OR REPLACE VIEW viw_ai_useful_forms AS | |
| 13 | +SELECT | |
| 14 | + m.sId AS sModuleId, -- 菜单窗体id (= 接口 sModelsId / ai_tool.sSrcFormId) | |
| 15 | + m.sChinese AS sModuleName, -- 窗体/菜单名称 | |
| 16 | + m.sModelType AS sModelType, -- 前端路由/业务域(可能为空) | |
| 17 | + f.sId AS sFormId, -- 表单id (= 接口路径 gdsconfigformmasterId) | |
| 18 | + f.sChinese AS sFormTitle, -- 表单标题 | |
| 19 | + f.sTbName AS sDataSource, -- 数据源(表/视图/过程) | |
| 20 | + f.sType AS sExecType, -- 执行类型 table/view/proc | |
| 21 | + CAST(f.bGrd AS UNSIGNED) AS bIsGrid, -- 1=列表型窗体, 0=明细型 (收窄开关) | |
| 22 | + CAST(m.bVisible AS UNSIGNED) AS bVisibleMenu, -- 1=主菜单可见, 0=次级/查询窗体 (收窄开关) | |
| 23 | + m.iOrder AS iOrder -- 菜单排序 | |
| 24 | +FROM gdsmodule m | |
| 25 | +JOIN gdsconfigformmaster f ON f.sParentId = m.sId | |
| 26 | +WHERE IFNULL(m.bInvalid, 0) = 0 | |
| 27 | + AND IFNULL(f.sTbName, '') <> '' | |
| 28 | + AND f.sType IN ('table', 'view', 'proc') | |
| 29 | + AND f.sTbName NOT LIKE 'gds%' | |
| 30 | + AND f.sTbName NOT LIKE 'sys%' | |
| 31 | + AND IFNULL(m.sModelType, '') NOT LIKE 'commonAuto/Bi%' | |
| 32 | + AND IFNULL(m.sModelType, '') NOT LIKE '%IndexPage' | |
| 33 | + AND IFNULL(m.sModelType, '') NOT LIKE 'http%' | |
| 34 | + AND IFNULL(m.sModelType, '') NOT LIKE '%ipAddress%' | |
| 35 | + AND IFNULL(m.sModelType, '') NOT LIKE 'modal/%' | |
| 36 | + AND IFNULL(m.sModelType, '') NOT IN ( | |
| 37 | + 'gdsmodule', 'gdsconfigformmaster', 'gdsconfigtbmaster', | |
| 38 | + 'gdsformconst', 'gdsjurisdiction', 'gdsconfigcharmaster', 'gdsinterfacemaster' | |
| 39 | + ); | ... | ... |
src/main/java/com/xly/config/AgentConfig.java
| ... | ... | @@ -30,6 +30,9 @@ public class AgentConfig { |
| 30 | 30 | @Value("${langchain4j.ollama.chat-model-name}") |
| 31 | 31 | private String chatModelName; |
| 32 | 32 | |
| 33 | + @org.springframework.beans.factory.annotation.Autowired | |
| 34 | + private TracingChatModelListener tracingListener; | |
| 35 | + | |
| 33 | 36 | /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ |
| 34 | 37 | @Bean("agentStreamingModel") |
| 35 | 38 | public OllamaStreamingChatModel agentStreamingModel() { |
| ... | ... | @@ -42,6 +45,7 @@ public class AgentConfig { |
| 42 | 45 | // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 |
| 43 | 46 | .think(false) |
| 44 | 47 | .returnThinking(false) |
| 48 | + .listeners(java.util.List.of(tracingListener)) | |
| 45 | 49 | .timeout(Duration.ofSeconds(180)) |
| 46 | 50 | .build(); |
| 47 | 51 | } | ... | ... |
src/main/java/com/xly/config/TracingChatModelListener.java
0 → 100644
| 1 | +package com.xly.config; | |
| 2 | + | |
| 3 | +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; | |
| 4 | +import dev.langchain4j.model.chat.listener.ChatModelListener; | |
| 5 | +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; | |
| 6 | +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; | |
| 7 | +import dev.langchain4j.model.output.TokenUsage; | |
| 8 | +import org.slf4j.Logger; | |
| 9 | +import org.slf4j.LoggerFactory; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * LLM 调用观测(轻量 tracing)—— 每次模型调用记录耗时 / token 用量 / 错误到 `com.xly.trace.llm` 日志。 | |
| 14 | + * | |
| 15 | + * <p>这是 **Langfuse 的接入点**:Langfuse 本体是需自托管的观测服务(需要实例 + 密钥),此处的 | |
| 16 | + * onResponse/onError 就是把 span 转发给 Langfuse 的挂钩位;在没有实例的环境下先落到日志,保证 | |
| 17 | + * 「LLM 可观测」这一能力有实现、可随时对接 Langfuse。业务审计另见 `ai_audit_log`(与 LLM tracing 分离)。 | |
| 18 | + */ | |
| 19 | +@Component | |
| 20 | +public class TracingChatModelListener implements ChatModelListener { | |
| 21 | + | |
| 22 | + private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm"); | |
| 23 | + | |
| 24 | + @Override | |
| 25 | + public void onRequest(ChatModelRequestContext ctx) { | |
| 26 | + ctx.attributes().put("t0", System.nanoTime()); | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Override | |
| 30 | + public void onResponse(ChatModelResponseContext ctx) { | |
| 31 | + long ms = elapsedMs(ctx.attributes().get("t0")); | |
| 32 | + String tok = "?"; | |
| 33 | + try { | |
| 34 | + TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage(); | |
| 35 | + if (u != null) { | |
| 36 | + tok = u.inputTokenCount() + "/" + u.outputTokenCount(); | |
| 37 | + } | |
| 38 | + } catch (Exception ignore) { | |
| 39 | + } | |
| 40 | + log.info("LLM ok {}ms tokens(in/out)={}", ms, tok); | |
| 41 | + // Langfuse 接入点:此处可 forward 一个 span(model, prompt, completion, latency, tokens)。 | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Override | |
| 45 | + public void onError(ChatModelErrorContext ctx) { | |
| 46 | + Throwable e = ctx.error(); | |
| 47 | + log.warn("LLM error: {}", e == null ? "?" : e.getMessage()); | |
| 48 | + } | |
| 49 | + | |
| 50 | + private long elapsedMs(Object t0) { | |
| 51 | + if (t0 instanceof Long) { | |
| 52 | + return (System.nanoTime() - (Long) t0) / 1_000_000L; | |
| 53 | + } | |
| 54 | + return -1; | |
| 55 | + } | |
| 56 | +} | ... | ... |
src/main/java/com/xly/service/ErpClient.java
| ... | ... | @@ -174,6 +174,57 @@ public class ErpClient { |
| 174 | 174 | } |
| 175 | 175 | } |
| 176 | 176 | |
| 177 | + /** 取一个新主键 uuid(ERP `/getUuid`)。 */ | |
| 178 | + public String newUuid() { | |
| 179 | + try { | |
| 180 | + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) | |
| 181 | + .header("Authorization", token()) | |
| 182 | + .timeout(Duration.ofSeconds(15)) | |
| 183 | + .GET() | |
| 184 | + .build(); | |
| 185 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 186 | + JsonNode root = mapper.readTree(resp.body()); | |
| 187 | + return root.path("dataset").path("rows").path(0).asText(null); | |
| 188 | + } catch (Exception e) { | |
| 189 | + throw new RuntimeException("取 uuid 失败: " + e.getMessage(), e); | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ | |
| 194 | + public JsonNode createForm(String table, Map<String, Object> columns) { | |
| 195 | + JsonNode root = doCreate(table, columns, token()); | |
| 196 | + if (root.path("code").asInt() == -2) { | |
| 197 | + login(); | |
| 198 | + root = doCreate(table, columns, token()); | |
| 199 | + } | |
| 200 | + return root; | |
| 201 | + } | |
| 202 | + | |
| 203 | + private JsonNode doCreate(String table, Map<String, Object> columns, String tok) { | |
| 204 | + try { | |
| 205 | + String url = baseUrl + "/business/addBusinessData"; | |
| 206 | + Map<String, Object> dataItem = new LinkedHashMap<>(); | |
| 207 | + dataItem.put("sTable", table); | |
| 208 | + dataItem.put("column", columns); | |
| 209 | + Map<String, Object> body = new LinkedHashMap<>(); | |
| 210 | + body.put("sMakePerson", username); | |
| 211 | + body.put("sBrandsId", brand); | |
| 212 | + body.put("sSubsidiaryId", subsidiary); | |
| 213 | + body.put("sLanguage", "sChinese"); | |
| 214 | + body.put("data", List.of(dataItem)); | |
| 215 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | |
| 216 | + .header("Content-Type", "application/json;charset=UTF-8") | |
| 217 | + .header("Authorization", tok) | |
| 218 | + .timeout(Duration.ofSeconds(30)) | |
| 219 | + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) | |
| 220 | + .build(); | |
| 221 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 222 | + return mapper.readTree(resp.body()); | |
| 223 | + } catch (Exception e) { | |
| 224 | + throw new RuntimeException("ERP 新增异常: " + e.getMessage(), e); | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 177 | 228 | private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { |
| 178 | 229 | try { |
| 179 | 230 | String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; | ... | ... |
src/main/java/com/xly/service/OpService.java
| ... | ... | @@ -35,6 +35,18 @@ public class OpService { |
| 35 | 35 | return sId; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | + /** 暂存一条 draft(payload 版,用于 create:sPayload=列->值 JSON)。 */ | |
| 39 | + public String createDraftPayload(String userId, String opType, String formId, String moduleId, | |
| 40 | + String table, String payload, String description) { | |
| 41 | + String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); | |
| 42 | + jdbc.update( | |
| 43 | + "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + | |
| 44 | + "sPayload,sDescription,sStatus,tCreateDate) VALUES(?,?,?,?,?,?,?,?, 'draft', NOW())", | |
| 45 | + sId, userId, opType, formId, moduleId, table, payload, description); | |
| 46 | + audit.log(userId, null, "propose", table + " (create)", description, true, "draft staged (" + sId + ")"); | |
| 47 | + return sId; | |
| 48 | + } | |
| 49 | + | |
| 38 | 50 | /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ |
| 39 | 51 | public void attachConversation(String opId, String convId) { |
| 40 | 52 | jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -43,6 +43,8 @@ public class SystemPromptService { |
| 43 | 43 | - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ |
| 44 | 44 | (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 |
| 45 | 45 | - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 |
| 46 | + - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ | |
| 47 | + fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 | |
| 46 | 48 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
| 47 | 49 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ |
| 48 | 50 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 |
| ... | ... | @@ -51,8 +53,8 @@ public class SystemPromptService { |
| 51 | 53 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 |
| 52 | 54 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 |
| 53 | 55 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 54 | - 4. 写操作:改字段用 proposeUpdate、删除记录用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ | |
| 55 | - 真正的修改/删除要用户在对话内点【确认】才发生,你**绝不能声称已经改好/删好**。新增单据 / 审核等其它写操作仍在开发中,如实告知。 | |
| 56 | + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ | |
| 57 | + 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 | |
| 56 | 58 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ |
| 57 | 59 | 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 |
| 58 | 60 | """.formatted(renderDomainMap()); | ... | ... |
src/main/java/com/xly/tool/FormCollectTool.java
0 → 100644
| 1 | +package com.xly.tool; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.xly.service.FormResolverService; | |
| 5 | +import dev.langchain4j.agent.tool.P; | |
| 6 | +import dev.langchain4j.agent.tool.Tool; | |
| 7 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 8 | +import org.springframework.stereotype.Component; | |
| 9 | + | |
| 10 | +import java.util.ArrayList; | |
| 11 | +import java.util.HashSet; | |
| 12 | +import java.util.LinkedHashMap; | |
| 13 | +import java.util.List; | |
| 14 | +import java.util.Map; | |
| 15 | +import java.util.Set; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 | |
| 19 | + * | |
| 20 | + * <p>字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段), | |
| 21 | + * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走 | |
| 22 | + * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 | |
| 23 | + */ | |
| 24 | +@Component | |
| 25 | +public class FormCollectTool { | |
| 26 | + | |
| 27 | + private final FormResolverService resolver; | |
| 28 | + private final JdbcTemplate jdbc; | |
| 29 | + private final ObjectMapper mapper; | |
| 30 | + | |
| 31 | + public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) { | |
| 32 | + this.resolver = resolver; | |
| 33 | + this.jdbc = jdbc; | |
| 34 | + this.mapper = mapper; | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。" | |
| 38 | + + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。") | |
| 39 | + public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { | |
| 40 | + if (entityKeyword == null || entityKeyword.isBlank()) { | |
| 41 | + return "{\"error\":\"缺少实体类型\"}"; | |
| 42 | + } | |
| 43 | + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); | |
| 44 | + if (form == null) { | |
| 45 | + return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; | |
| 46 | + } | |
| 47 | + String table = String.valueOf(form.get("sDataSource")); | |
| 48 | + List<String> fields = new ArrayList<>(); | |
| 49 | + Set<String> seen = new HashSet<>(); | |
| 50 | + try { | |
| 51 | + List<Map<String, Object>> rows = jdbc.queryForList( | |
| 52 | + "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " + | |
| 53 | + "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " + | |
| 54 | + "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table); | |
| 55 | + for (Map<String, Object> r : rows) { | |
| 56 | + Object zh = r.get("sChinese"); | |
| 57 | + if (zh != null && seen.add(zh.toString()) && fields.size() < 8) { | |
| 58 | + fields.add(zh.toString()); | |
| 59 | + } | |
| 60 | + } | |
| 61 | + } catch (Exception ignore) { | |
| 62 | + } | |
| 63 | + if (fields.isEmpty()) { | |
| 64 | + return "{\"error\":\"该实体没有可填字段\"}"; | |
| 65 | + } | |
| 66 | + Map<String, Object> out = new LinkedHashMap<>(); | |
| 67 | + out.put("formCollect", true); | |
| 68 | + out.put("entity", entityKeyword.trim()); | |
| 69 | + out.put("fields", fields); | |
| 70 | + out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); | |
| 71 | + try { | |
| 72 | + return mapper.writeValueAsString(out); | |
| 73 | + } catch (Exception e) { | |
| 74 | + return "{\"error\":\"内部错误\"}"; | |
| 75 | + } | |
| 76 | + } | |
| 77 | +} | ... | ... |
src/main/java/com/xly/tool/ProposeWriteTool.java
| ... | ... | @@ -10,9 +10,12 @@ import dev.langchain4j.agent.tool.Tool; |
| 10 | 10 | import org.springframework.jdbc.core.JdbcTemplate; |
| 11 | 11 | import org.springframework.stereotype.Component; |
| 12 | 12 | |
| 13 | +import java.util.ArrayList; | |
| 14 | +import java.util.Iterator; | |
| 13 | 15 | import java.util.LinkedHashMap; |
| 14 | 16 | import java.util.List; |
| 15 | 17 | import java.util.Map; |
| 18 | +import java.util.Set; | |
| 16 | 19 | |
| 17 | 20 | /** |
| 18 | 21 | * ProposeWrite 工具(写操作,人在环)。 |
| ... | ... | @@ -187,6 +190,115 @@ public class ProposeWriteTool { |
| 187 | 190 | return toJson(out); |
| 188 | 191 | } |
| 189 | 192 | |
| 193 | + @Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。" | |
| 194 | + + "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。") | |
| 195 | + public String proposeCreate( | |
| 196 | + @P("实体类型,如 客户 / 物料") String entityKeyword, | |
| 197 | + @P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) { | |
| 198 | + | |
| 199 | + if (isBlank(entityKeyword)) { | |
| 200 | + return err("缺少实体类型。"); | |
| 201 | + } | |
| 202 | + Map<String, Object> form = resolveForm(entityKeyword.trim()); | |
| 203 | + if (form == null) { | |
| 204 | + return err("找不到「" + entityKeyword + "」对应的可新增主表。"); | |
| 205 | + } | |
| 206 | + String formId = str(form.get("sFormId")); | |
| 207 | + String moduleId = str(form.get("sModuleId")); | |
| 208 | + String table = str(form.get("sDataSource")); | |
| 209 | + if (!authz.canAccessModule(moduleId)) { | |
| 210 | + return err("你没有新增「" + entityKeyword + "」的权限。"); | |
| 211 | + } | |
| 212 | + | |
| 213 | + Map<String, Object> col = new LinkedHashMap<>(); | |
| 214 | + List<String> descParts = new ArrayList<>(); | |
| 215 | + try { | |
| 216 | + if (!isBlank(fieldsJson)) { | |
| 217 | + JsonNode fj = mapper.readTree(fieldsJson.trim()); | |
| 218 | + Iterator<Map.Entry<String, JsonNode>> it = fj.fields(); | |
| 219 | + while (it.hasNext()) { | |
| 220 | + Map.Entry<String, JsonNode> e = it.next(); | |
| 221 | + String zh = e.getKey(); | |
| 222 | + String v = e.getValue().asText(""); | |
| 223 | + String colName = queryOne( | |
| 224 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + | |
| 225 | + "ORDER BY iFormUses DESC LIMIT 1", table, zh, "%" + zh + "%"); | |
| 226 | + if (colName != null) { | |
| 227 | + col.put(colName, v); | |
| 228 | + descParts.add(zh + "=" + v); | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + } catch (Exception ex) { | |
| 233 | + return err("字段 JSON 解析失败:" + ex.getMessage()); | |
| 234 | + } | |
| 235 | + if (descParts.isEmpty()) { | |
| 236 | + return err("请至少提供一个有效字段(如客户名称)。"); | |
| 237 | + } | |
| 238 | + | |
| 239 | + // 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过) | |
| 240 | + for (String rc : requiredCols(table)) { | |
| 241 | + if (col.containsKey(rc)) { | |
| 242 | + continue; | |
| 243 | + } | |
| 244 | + if ("sId".equals(rc)) { | |
| 245 | + col.put(rc, erp.newUuid()); | |
| 246 | + } else if (rc.endsWith("Id")) { | |
| 247 | + String d = commonValue(table, rc); // 外键:取现有最常见值兜底 | |
| 248 | + col.put(rc, d == null ? "" : d); | |
| 249 | + } else if (rc.endsWith("No")) { | |
| 250 | + col.put(rc, "AI" + (System.currentTimeMillis() % 1000000000L)); | |
| 251 | + } else { | |
| 252 | + col.put(rc, ""); | |
| 253 | + } | |
| 254 | + } | |
| 255 | + col.putIfAbsent("sId", erp.newUuid()); | |
| 256 | + | |
| 257 | + String payload; | |
| 258 | + try { | |
| 259 | + payload = mapper.writeValueAsString(col); | |
| 260 | + } catch (Exception e) { | |
| 261 | + return err("内部错误:" + e.getMessage()); | |
| 262 | + } | |
| 263 | + String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); | |
| 264 | + String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); | |
| 265 | + | |
| 266 | + Map<String, Object> out = new LinkedHashMap<>(); | |
| 267 | + out.put("opId", opId); | |
| 268 | + out.put("summary", description); | |
| 269 | + out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。"); | |
| 270 | + return toJson(out); | |
| 271 | + } | |
| 272 | + | |
| 273 | + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ | |
| 274 | + private List<String> requiredCols(String table) { | |
| 275 | + List<String> out = new ArrayList<>(); | |
| 276 | + try { | |
| 277 | + List<Map<String, Object>> rows = jdbc.queryForList( | |
| 278 | + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + | |
| 279 | + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); | |
| 280 | + Set<String> skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); | |
| 281 | + for (Map<String, Object> r : rows) { | |
| 282 | + String c = str(r.get("COLUMN_NAME")); | |
| 283 | + if (c != null && !skip.contains(c)) { | |
| 284 | + out.add(c); | |
| 285 | + } | |
| 286 | + } | |
| 287 | + } catch (Exception ignore) { | |
| 288 | + } | |
| 289 | + return out; | |
| 290 | + } | |
| 291 | + | |
| 292 | + /** 外键兜底:该列现有的最常见非空值。列名来自 KG/info_schema(可信)。 */ | |
| 293 | + private String commonValue(String table, String col) { | |
| 294 | + try { | |
| 295 | + return queryOne("SELECT `" + col + "` FROM `" + table + "` WHERE `" + col + "` IS NOT NULL AND `" + col + | |
| 296 | + "`<>'' GROUP BY `" + col + "` ORDER BY COUNT(*) DESC LIMIT 1"); | |
| 297 | + } catch (Exception e) { | |
| 298 | + return null; | |
| 299 | + } | |
| 300 | + } | |
| 301 | + | |
| 190 | 302 | /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ |
| 191 | 303 | private Map<String, Object> resolveForm(String entityKeyword) { |
| 192 | 304 | try { | ... | ... |
src/main/java/com/xly/tool/QueryTool.java
| ... | ... | @@ -42,38 +42,63 @@ public class QueryTool { |
| 42 | 42 | return "请描述要查询统计的内容。"; |
| 43 | 43 | } |
| 44 | 44 | String hint = schemaHint(question); |
| 45 | - String prompt = """ | |
| 45 | + String sql = null; | |
| 46 | + String lastErr = null; | |
| 47 | + // 自修复重试:SQL 校验/执行报错就把错误喂回模型重新生成,最多 3 次 | |
| 48 | + for (int attempt = 0; attempt < 3; attempt++) { | |
| 49 | + try { | |
| 50 | + sql = cleanSql(sqlModel.chat(buildPrompt(hint, question, sql, lastErr))); | |
| 51 | + } catch (Exception e) { | |
| 52 | + return "生成查询失败:" + e.getMessage(); | |
| 53 | + } | |
| 54 | + String reject = validate(sql); | |
| 55 | + if (reject != null) { | |
| 56 | + lastErr = reject; | |
| 57 | + if (attempt < 2) { | |
| 58 | + continue; | |
| 59 | + } | |
| 60 | + audit.log(null, null, "query", "REJECTED", sql, false, reject); | |
| 61 | + return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | |
| 62 | + } | |
| 63 | + String limited = forceLimit(sql); | |
| 64 | + try { | |
| 65 | + List<Map<String, Object>> rows = jdbc.queryForList(limited); | |
| 66 | + audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); | |
| 67 | + return formatRows(rows); | |
| 68 | + } catch (Exception e) { | |
| 69 | + lastErr = rootMsg(e); | |
| 70 | + if (attempt < 2) { | |
| 71 | + continue; // 下一轮把错误喂回模型自修复 | |
| 72 | + } | |
| 73 | + audit.log(null, null, "query", "fail", limited, false, lastErr); | |
| 74 | + return "查询执行失败(已尝试自修复):" + lastErr; | |
| 75 | + } | |
| 76 | + } | |
| 77 | + return "查询失败。"; | |
| 78 | + } | |
| 79 | + | |
| 80 | + private String buildPrompt(String hint, String question, String prevSql, String prevErr) { | |
| 81 | + String repair = (prevSql == null || prevErr == null) ? "" : | |
| 82 | + "\n\n上一条 SQL:\n" + prevSql + "\n执行/校验报错:" + prevErr + | |
| 83 | + "\n请**修正该错误**后重新生成一条正确的 SELECT(注意用对表名列名、别用中文别名)。"; | |
| 84 | + return """ | |
| 46 | 85 | 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 |
| 47 | 86 | 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): |
| 48 | 87 | %s |
| 49 | 88 | 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); |
| 50 | 89 | **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; |
| 51 | 90 | 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 |
| 52 | - 问题:%s | |
| 53 | - """.formatted(hint, question); | |
| 54 | - | |
| 55 | - String sql; | |
| 56 | - try { | |
| 57 | - sql = cleanSql(sqlModel.chat(prompt)); | |
| 58 | - } catch (Exception e) { | |
| 59 | - return "生成查询失败:" + e.getMessage(); | |
| 60 | - } | |
| 61 | - | |
| 62 | - String reject = validate(sql); | |
| 63 | - if (reject != null) { | |
| 64 | - audit.log(null, null, "query", "REJECTED", sql, false, reject); | |
| 65 | - return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; | |
| 66 | - } | |
| 67 | - sql = forceLimit(sql); | |
| 91 | + 问题:%s%s | |
| 92 | + """.formatted(hint, question, repair); | |
| 93 | + } | |
| 68 | 94 | |
| 69 | - try { | |
| 70 | - List<Map<String, Object>> rows = jdbc.queryForList(sql); | |
| 71 | - audit.log(null, null, "query", "ok", sql, true, "rows=" + rows.size()); | |
| 72 | - return formatRows(rows); | |
| 73 | - } catch (Exception e) { | |
| 74 | - audit.log(null, null, "query", "fail", sql, false, e.getMessage()); | |
| 75 | - return "查询执行失败:" + e.getMessage(); | |
| 95 | + private String rootMsg(Throwable e) { | |
| 96 | + Throwable r = e; | |
| 97 | + while (r.getCause() != null && r.getCause() != r) { | |
| 98 | + r = r.getCause(); | |
| 76 | 99 | } |
| 100 | + String m = r.getMessage(); | |
| 101 | + return m == null ? e.toString() : (m.length() > 300 ? m.substring(0, 300) : m); | |
| 77 | 102 | } |
| 78 | 103 | |
| 79 | 104 | /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ | ... | ... |
src/main/java/com/xly/web/AgentChatController.java
| ... | ... | @@ -94,7 +94,8 @@ public class AgentChatController { |
| 94 | 94 | send(emitter, "reset", ""); |
| 95 | 95 | try { |
| 96 | 96 | String toolName = te.request() == null ? "" : te.request().name(); |
| 97 | - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) { | |
| 97 | + if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName) | |
| 98 | + || "proposeCreate".equals(toolName)) && te.result() != null) { | |
| 98 | 99 | JsonNode r = mapper.readTree(te.result()); |
| 99 | 100 | String opId = r.path("opId").asText(null); |
| 100 | 101 | if (opId != null && !opId.isBlank()) { | ... | ... |
src/main/java/com/xly/web/OpController.java
| 1 | 1 | package com.xly.web; |
| 2 | 2 | |
| 3 | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | 5 | import com.xly.service.AuditService; |
| 5 | 6 | import com.xly.service.ErpClient; |
| 6 | 7 | import com.xly.service.OpService; |
| ... | ... | @@ -31,11 +32,13 @@ public class OpController { |
| 31 | 32 | private final OpService ops; |
| 32 | 33 | private final ErpClient erp; |
| 33 | 34 | private final AuditService audit; |
| 35 | + private final ObjectMapper mapper; | |
| 34 | 36 | |
| 35 | - public OpController(OpService ops, ErpClient erp, AuditService audit) { | |
| 37 | + public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) { | |
| 36 | 38 | this.ops = ops; |
| 37 | 39 | this.erp = erp; |
| 38 | 40 | this.audit = audit; |
| 41 | + this.mapper = mapper; | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ |
| ... | ... | @@ -62,7 +65,11 @@ public class OpController { |
| 62 | 65 | try { |
| 63 | 66 | String opType = str(op.get("sOpType")); |
| 64 | 67 | JsonNode r; |
| 65 | - if ("delete".equals(opType)) { | |
| 68 | + if ("create".equals(opType)) { | |
| 69 | + @SuppressWarnings("unchecked") | |
| 70 | + Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); | |
| 71 | + r = erp.createForm(str(op.get("sTargetTable")), columns); | |
| 72 | + } else if ("delete".equals(opType)) { | |
| 66 | 73 | r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); |
| 67 | 74 | } else { |
| 68 | 75 | r = erp.updateForm( | ... | ... |