diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 index bd8896b..bd8896b --- a/mvnw +++ b/mvnw diff --git a/sql/ai_op_queue.sql b/sql/ai_op_queue.sql index 5fc81e8..1f5cbcd 100644 --- a/sql/ai_op_queue.sql +++ b/sql/ai_op_queue.sql @@ -1,25 +1,35 @@ -- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 -- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 --- v1 覆盖 update(改现有记录的字段);create/delete/examine 后续扩展。 +-- 覆盖 update / create / delete / examine / generate(sOpType 区分)。 +-- update/delete/examine:用 sTargetBillId 指向目标记录;改字段用 sField/sNewValue。 +-- create/generate:整表 payload 存 sPayload(列->值 JSON);generate 的源引用存 sSourceRef。 +-- bAutoExecute=1(如报价)→ ERP 确认后自动执行并回 sResultBillId;=0 → 预填表单让用户手动提交。 CREATE TABLE IF NOT EXISTS ai_op_queue ( - sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token + sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token sUserId varchar(64), sConversationId varchar(96), sBrandsId varchar(32), sSubsidiaryId varchar(32), - sOpType varchar(16), -- update | create | delete | examine + sOpType varchar(16), -- update | create | delete | examine | generate sTargetFormId varchar(64), sTargetModuleId varchar(64), sTargetTable varchar(64), - sTargetBillId varchar(64), -- 目标记录 sId(update/delete 用) + sTargetBillId varchar(64), -- 目标记录 sId(update/delete/examine 用) sField varchar(64), -- 目标列(技术名) sFieldLabel varchar(128), -- 目标列中文名 sOldValue varchar(500), sNewValue varchar(500), + sPayload text, -- create/update 的列->值 JSON(整表写入) + sSourceRef text, -- generate:源表单 + 选中明细 sId 集合 JSON + bAutoExecute tinyint NOT NULL DEFAULT 0, -- 1=ERP 确认后自动执行;0=预填表单等用户提交 sDescription varchar(500), -- 人类可读的改动描述 - sStatus varchar(16), -- draft | confirmed | executed | failed | cancelled + sStatus varchar(16), -- draft | confirmed | executing | executed | failed | cancelled | expired + sResultBillId varchar(64), -- 执行后生成/影响的单据 id sResultMsg varchar(500), + sErrorMsg varchar(1000), tCreateDate datetime, tConfirmDate datetime, + tExecutedDate datetime, + tExpireAt datetime, KEY idx_conv (sConversationId, sStatus) ); diff --git a/sql/ai_skill.sql b/sql/ai_skill.sql new file mode 100644 index 0000000..3dbfc3a --- /dev/null +++ b/sql/ai_skill.sql @@ -0,0 +1,25 @@ +-- ai_skill:Skill 注册表(架构 §6)。 +-- Skill = 针对重复任务的 playbook(如 新建报价 / 月度对账 / 库存盘点),用工具但不是工具。 +-- 渐进披露:name + 何时用(sWhenToUse) 进 system prompt(便宜、常驻);load_skill(name) 工具 +-- 按需注入完整指令(sInstructions)。sSuggested = 建议用到的工具/表单(逗号分隔,仅提示)。 +CREATE TABLE IF NOT EXISTS ai_skill ( + sId varchar(64) NOT NULL PRIMARY KEY, + sName varchar(64) NOT NULL, -- Skill 名(load_skill 的入参) + sWhenToUse varchar(255) NOT NULL, -- 何时用(一句话,进 system prompt) + sInstructions text NOT NULL, -- 详细 playbook(load_skill 时注入) + sSuggested varchar(255) NULL, -- 建议工具/表单(逗号分隔) + bEnabled tinyint NOT NULL DEFAULT 1, + iOrder int NOT NULL DEFAULT 100, + tCreateDate datetime NULL +); + +INSERT INTO ai_skill (sId,sName,sWhenToUse,sInstructions,sSuggested,bEnabled,iOrder,tCreateDate) VALUES +('skill-new-quote','新建报价','用户要为某客户新建/录入一张报价单时', + '新建报价的标准流程:\n1) 先用 findForms("报价") 定位报价主表单,拿到 formId/moduleId。\n2) 报价字段较多(客户、币种、含税、明细行等),用 collectForm(该 formId) 弹出表单让用户逐项填写,而不是逐个追问。\n3) 用户填完后,用 proposeCreate 生成待确认的新增提议;务必让用户在对话内点【确认】后才真正落单。\n4) 报价属于自动执行类写入(bAutoExecute),确认后 ERP 会自动生成正式报价单并回链接。', + 'findForms, collectForm, proposeCreate',1,10,NOW()), +('skill-monthly-ar','月度对账/超期应收催收','用户要做应收对账、查超期未收款、催收时', + '应收对账/催收流程:\n1) 用 queryData 或 findForms("应收") 找到应收台账/收款单据。\n2) 按客户汇总未收金额,用 queryData 做“按客户分组 SUM(未收金额) 并按账龄排序”。\n3) 列出超期(如超过约定账期)客户与金额,提示用户重点催收。\n4) 只读分析,不做任何写操作。', + 'queryData, findForms, readFormData',1,20,NOW()), +('skill-inventory-check','库存查询/盘点','用户要查某物料/产品的库存、库存台账、进出明细时', + '库存查询流程:\n1) 物料库存看 elematerialsstock 相关表单,产品库存看 productstock。\n2) 用 lookupRecord(物料/产品, 名称) 查单个;用 readFormData 看列表;用 queryData 做汇总。\n3) 严禁传 bUpdate 之类写参数(只读)。\n4) 需要出入库明细时找 *_inoutdetail 视图。', + 'lookupRecord, readFormData, queryData',1,30,NOW()); diff --git a/sql/viw_ai_useful_forms.sql b/sql/viw_ai_useful_forms.sql new file mode 100644 index 0000000..5e35662 --- /dev/null +++ b/sql/viw_ai_useful_forms.sql @@ -0,0 +1,39 @@ +-- ============================================================ +-- 视图:viw_ai_useful_forms —— ERP「有用业务表单」目录 +-- 合并 gdsmodule(菜单窗体) + gdsconfigformmaster(表单配置) +-- 过滤原则:保召回优先,只剔除"必然无用"的项,再用字段让调用方自行收窄 +-- 剔除:失效 / 系统配置源(gds*/sys*) / BI报表 / 首页 / 外链 / 弹窗 / 系统配置窗体 +-- 保留:有业务数据源(表/视图/过程)的表单 +-- 收窄开关(作为字段暴露,不写死在过滤里,避免误删"明细/查询"类窗体): +-- bVisibleMenu = 1 → 主菜单可见的窗体(约370张,精度高) +-- bIsGrid = 1 → 列表/表格型窗体(适合出数据列表) +-- 调接口用法:POST /business/getBusinessDataByFormcustomId/{sFormId}?sModelsId={sModuleId} +-- ============================================================ +CREATE OR REPLACE VIEW viw_ai_useful_forms AS +SELECT + m.sId AS sModuleId, -- 菜单窗体id (= 接口 sModelsId / ai_tool.sSrcFormId) + m.sChinese AS sModuleName, -- 窗体/菜单名称 + m.sModelType AS sModelType, -- 前端路由/业务域(可能为空) + f.sId AS sFormId, -- 表单id (= 接口路径 gdsconfigformmasterId) + f.sChinese AS sFormTitle, -- 表单标题 + f.sTbName AS sDataSource, -- 数据源(表/视图/过程) + f.sType AS sExecType, -- 执行类型 table/view/proc + CAST(f.bGrd AS UNSIGNED) AS bIsGrid, -- 1=列表型窗体, 0=明细型 (收窄开关) + CAST(m.bVisible AS UNSIGNED) AS bVisibleMenu, -- 1=主菜单可见, 0=次级/查询窗体 (收窄开关) + m.iOrder AS iOrder -- 菜单排序 +FROM gdsmodule m +JOIN gdsconfigformmaster f ON f.sParentId = m.sId +WHERE IFNULL(m.bInvalid, 0) = 0 + AND IFNULL(f.sTbName, '') <> '' + AND f.sType IN ('table', 'view', 'proc') + AND f.sTbName NOT LIKE 'gds%' + AND f.sTbName NOT LIKE 'sys%' + AND IFNULL(m.sModelType, '') NOT LIKE 'commonAuto/Bi%' + AND IFNULL(m.sModelType, '') NOT LIKE '%IndexPage' + AND IFNULL(m.sModelType, '') NOT LIKE 'http%' + AND IFNULL(m.sModelType, '') NOT LIKE '%ipAddress%' + AND IFNULL(m.sModelType, '') NOT LIKE 'modal/%' + AND IFNULL(m.sModelType, '') NOT IN ( + 'gdsmodule', 'gdsconfigformmaster', 'gdsconfigtbmaster', + 'gdsformconst', 'gdsjurisdiction', 'gdsconfigcharmaster', 'gdsinterfacemaster' + ); diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 9206bcb..153afe6 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -30,6 +30,9 @@ public class AgentConfig { @Value("${langchain4j.ollama.chat-model-name}") private String chatModelName; + @org.springframework.beans.factory.annotation.Autowired + private TracingChatModelListener tracingListener; + /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ @Bean("agentStreamingModel") public OllamaStreamingChatModel agentStreamingModel() { @@ -42,6 +45,7 @@ public class AgentConfig { // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 .think(false) .returnThinking(false) + .listeners(java.util.List.of(tracingListener)) .timeout(Duration.ofSeconds(180)) .build(); } diff --git a/src/main/java/com/xly/config/TracingChatModelListener.java b/src/main/java/com/xly/config/TracingChatModelListener.java new file mode 100644 index 0000000..436fcc0 --- /dev/null +++ b/src/main/java/com/xly/config/TracingChatModelListener.java @@ -0,0 +1,56 @@ +package com.xly.config; + +import dev.langchain4j.model.chat.listener.ChatModelErrorContext; +import dev.langchain4j.model.chat.listener.ChatModelListener; +import dev.langchain4j.model.chat.listener.ChatModelRequestContext; +import dev.langchain4j.model.chat.listener.ChatModelResponseContext; +import dev.langchain4j.model.output.TokenUsage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * LLM 调用观测(轻量 tracing)—— 每次模型调用记录耗时 / token 用量 / 错误到 `com.xly.trace.llm` 日志。 + * + *

这是 **Langfuse 的接入点**:Langfuse 本体是需自托管的观测服务(需要实例 + 密钥),此处的 + * onResponse/onError 就是把 span 转发给 Langfuse 的挂钩位;在没有实例的环境下先落到日志,保证 + * 「LLM 可观测」这一能力有实现、可随时对接 Langfuse。业务审计另见 `ai_audit_log`(与 LLM tracing 分离)。 + */ +@Component +public class TracingChatModelListener implements ChatModelListener { + + private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm"); + + @Override + public void onRequest(ChatModelRequestContext ctx) { + ctx.attributes().put("t0", System.nanoTime()); + } + + @Override + public void onResponse(ChatModelResponseContext ctx) { + long ms = elapsedMs(ctx.attributes().get("t0")); + String tok = "?"; + try { + TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage(); + if (u != null) { + tok = u.inputTokenCount() + "/" + u.outputTokenCount(); + } + } catch (Exception ignore) { + } + log.info("LLM ok {}ms tokens(in/out)={}", ms, tok); + // Langfuse 接入点:此处可 forward 一个 span(model, prompt, completion, latency, tokens)。 + } + + @Override + public void onError(ChatModelErrorContext ctx) { + Throwable e = ctx.error(); + log.warn("LLM error: {}", e == null ? "?" : e.getMessage()); + } + + private long elapsedMs(Object t0) { + if (t0 instanceof Long) { + return (System.nanoTime() - (Long) t0) / 1_000_000L; + } + return -1; + } +} diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 97c0281..55059d4 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -174,6 +174,57 @@ public class ErpClient { } } + /** 取一个新主键 uuid(ERP `/getUuid`)。 */ + public String newUuid() { + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) + .header("Authorization", token()) + .timeout(Duration.ofSeconds(15)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + JsonNode root = mapper.readTree(resp.body()); + return root.path("dataset").path("rows").path(0).asText(null); + } catch (Exception e) { + throw new RuntimeException("取 uuid 失败: " + e.getMessage(), e); + } + } + + /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ + public JsonNode createForm(String table, Map columns) { + JsonNode root = doCreate(table, columns, token()); + if (root.path("code").asInt() == -2) { + login(); + root = doCreate(table, columns, token()); + } + return root; + } + + private JsonNode doCreate(String table, Map columns, String tok) { + try { + String url = baseUrl + "/business/addBusinessData"; + Map dataItem = new LinkedHashMap<>(); + dataItem.put("sTable", table); + dataItem.put("column", columns); + Map body = new LinkedHashMap<>(); + body.put("sMakePerson", username); + body.put("sBrandsId", brand); + body.put("sSubsidiaryId", subsidiary); + body.put("sLanguage", "sChinese"); + body.put("data", List.of(dataItem)); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(30)) + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), 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); + } + } + private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { try { String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java index 210a931..c20e723 100644 --- a/src/main/java/com/xly/service/OpService.java +++ b/src/main/java/com/xly/service/OpService.java @@ -35,6 +35,18 @@ public class OpService { return sId; } + /** 暂存一条 draft(payload 版,用于 create:sPayload=列->值 JSON)。 */ + public String createDraftPayload(String userId, String opType, String formId, String moduleId, + String table, String payload, String description) { + String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); + jdbc.update( + "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + + "sPayload,sDescription,sStatus,tCreateDate) VALUES(?,?,?,?,?,?,?,?, 'draft', NOW())", + sId, userId, opType, formId, moduleId, table, payload, description); + audit.log(userId, null, "propose", table + " (create)", description, true, "draft staged (" + sId + ")"); + return sId; + } + /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ public void attachConversation(String opId, String convId) { jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 5b74de8..ef001ed 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -43,6 +43,8 @@ public class SystemPromptService { - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 + - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ + fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 @@ -51,8 +53,8 @@ public class SystemPromptService { 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. 写操作:改字段用 proposeUpdate、删除记录用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ - 真正的修改/删除要用户在对话内点【确认】才发生,你**绝不能声称已经改好/删好**。新增单据 / 审核等其它写操作仍在开发中,如实告知。 + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\ + 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 """.formatted(renderDomainMap()); diff --git a/src/main/java/com/xly/tool/FormCollectTool.java b/src/main/java/com/xly/tool/FormCollectTool.java new file mode 100644 index 0000000..741444d --- /dev/null +++ b/src/main/java/com/xly/tool/FormCollectTool.java @@ -0,0 +1,77 @@ +package com.xly.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.service.FormResolverService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 + * + *

字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段), + * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走 + * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 + */ +@Component +public class FormCollectTool { + + private final FormResolverService resolver; + private final JdbcTemplate jdbc; + private final ObjectMapper mapper; + + public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) { + this.resolver = resolver; + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。" + + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。") + public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { + if (entityKeyword == null || entityKeyword.isBlank()) { + return "{\"error\":\"缺少实体类型\"}"; + } + Map form = resolver.resolveMasterForm(entityKeyword.trim()); + if (form == null) { + return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; + } + String table = String.valueOf(form.get("sDataSource")); + List fields = new ArrayList<>(); + Set seen = new HashSet<>(); + try { + List> rows = jdbc.queryForList( + "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " + + "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " + + "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table); + for (Map r : rows) { + Object zh = r.get("sChinese"); + if (zh != null && seen.add(zh.toString()) && fields.size() < 8) { + fields.add(zh.toString()); + } + } + } catch (Exception ignore) { + } + if (fields.isEmpty()) { + return "{\"error\":\"该实体没有可填字段\"}"; + } + Map out = new LinkedHashMap<>(); + out.put("formCollect", true); + out.put("entity", entityKeyword.trim()); + out.put("fields", fields); + out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); + try { + return mapper.writeValueAsString(out); + } catch (Exception e) { + return "{\"error\":\"内部错误\"}"; + } + } +} diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java index 4db8a4b..33fe3ff 100644 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -10,9 +10,12 @@ import dev.langchain4j.agent.tool.Tool; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * ProposeWrite 工具(写操作,人在环)。 @@ -187,6 +190,115 @@ public class ProposeWriteTool { return toJson(out); } + @Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。" + + "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。") + public String proposeCreate( + @P("实体类型,如 客户 / 物料") String entityKeyword, + @P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) { + + if (isBlank(entityKeyword)) { + 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 (!authz.canAccessModule(moduleId)) { + return err("你没有新增「" + entityKeyword + "」的权限。"); + } + + Map col = new LinkedHashMap<>(); + List descParts = new ArrayList<>(); + try { + if (!isBlank(fieldsJson)) { + JsonNode fj = mapper.readTree(fieldsJson.trim()); + Iterator> it = fj.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + String zh = e.getKey(); + String v = e.getValue().asText(""); + String colName = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + + "ORDER BY iFormUses DESC LIMIT 1", table, zh, "%" + zh + "%"); + if (colName != null) { + col.put(colName, v); + descParts.add(zh + "=" + v); + } + } + } + } catch (Exception ex) { + return err("字段 JSON 解析失败:" + ex.getMessage()); + } + if (descParts.isEmpty()) { + return err("请至少提供一个有效字段(如客户名称)。"); + } + + // 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过) + for (String rc : requiredCols(table)) { + if (col.containsKey(rc)) { + continue; + } + if ("sId".equals(rc)) { + col.put(rc, erp.newUuid()); + } else if (rc.endsWith("Id")) { + String d = commonValue(table, rc); // 外键:取现有最常见值兜底 + col.put(rc, d == null ? "" : d); + } else if (rc.endsWith("No")) { + col.put(rc, "AI" + (System.currentTimeMillis() % 1000000000L)); + } else { + col.put(rc, ""); + } + } + col.putIfAbsent("sId", erp.newUuid()); + + String payload; + try { + payload = mapper.writeValueAsString(col); + } catch (Exception e) { + return err("内部错误:" + e.getMessage()); + } + String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); + String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); + + Map out = new LinkedHashMap<>(); + out.put("opId", opId); + out.put("summary", description); + out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。"); + return toJson(out); + } + + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ + private List requiredCols(String table) { + List out = new ArrayList<>(); + try { + List> rows = jdbc.queryForList( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); + Set skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); + for (Map r : rows) { + String c = str(r.get("COLUMN_NAME")); + if (c != null && !skip.contains(c)) { + out.add(c); + } + } + } catch (Exception ignore) { + } + return out; + } + + /** 外键兜底:该列现有的最常见非空值。列名来自 KG/info_schema(可信)。 */ + private String commonValue(String table, String col) { + try { + return queryOne("SELECT `" + col + "` FROM `" + table + "` WHERE `" + col + "` IS NOT NULL AND `" + col + + "`<>'' GROUP BY `" + col + "` ORDER BY COUNT(*) DESC LIMIT 1"); + } catch (Exception e) { + return null; + } + } + /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ private Map resolveForm(String entityKeyword) { try { diff --git a/src/main/java/com/xly/tool/QueryTool.java b/src/main/java/com/xly/tool/QueryTool.java index 8b7eddb..dc5f23b 100644 --- a/src/main/java/com/xly/tool/QueryTool.java +++ b/src/main/java/com/xly/tool/QueryTool.java @@ -42,38 +42,63 @@ public class QueryTool { return "请描述要查询统计的内容。"; } String hint = schemaHint(question); - String prompt = """ + String sql = null; + String lastErr = null; + // 自修复重试:SQL 校验/执行报错就把错误喂回模型重新生成,最多 3 次 + for (int attempt = 0; attempt < 3; attempt++) { + try { + sql = cleanSql(sqlModel.chat(buildPrompt(hint, question, sql, lastErr))); + } catch (Exception e) { + return "生成查询失败:" + e.getMessage(); + } + String reject = validate(sql); + if (reject != null) { + lastErr = reject; + if (attempt < 2) { + continue; + } + audit.log(null, null, "query", "REJECTED", sql, false, reject); + return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; + } + String limited = forceLimit(sql); + try { + List> rows = jdbc.queryForList(limited); + audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : "")); + return formatRows(rows); + } catch (Exception e) { + lastErr = rootMsg(e); + if (attempt < 2) { + continue; // 下一轮把错误喂回模型自修复 + } + audit.log(null, null, "query", "fail", limited, false, lastErr); + return "查询执行失败(已尝试自修复):" + lastErr; + } + } + return "查询失败。"; + } + + private String buildPrompt(String hint, String question, String prevSql, String prevErr) { + String repair = (prevSql == null || prevErr == null) ? "" : + "\n\n上一条 SQL:\n" + prevSql + "\n执行/校验报错:" + prevErr + + "\n请**修正该错误**后重新生成一条正确的 SELECT(注意用对表名列名、别用中文别名)。"; + return """ 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): %s 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。 - 问题:%s - """.formatted(hint, question); - - String sql; - try { - sql = cleanSql(sqlModel.chat(prompt)); - } catch (Exception e) { - return "生成查询失败:" + e.getMessage(); - } - - String reject = validate(sql); - if (reject != null) { - audit.log(null, null, "query", "REJECTED", sql, false, reject); - return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。"; - } - sql = forceLimit(sql); + 问题:%s%s + """.formatted(hint, question, repair); + } - try { - List> rows = jdbc.queryForList(sql); - audit.log(null, null, "query", "ok", sql, true, "rows=" + rows.size()); - return formatRows(rows); - } catch (Exception e) { - audit.log(null, null, "query", "fail", sql, false, e.getMessage()); - return "查询执行失败:" + e.getMessage(); + private String rootMsg(Throwable e) { + Throwable r = e; + while (r.getCause() != null && r.getCause() != r) { + r = r.getCause(); } + String m = r.getMessage(); + return m == null ? e.toString() : (m.length() > 300 ? m.substring(0, 300) : m); } /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 9573902..d8e107d 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -94,7 +94,8 @@ public class AgentChatController { send(emitter, "reset", ""); try { String toolName = te.request() == null ? "" : te.request().name(); - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) { + if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName) + || "proposeCreate".equals(toolName)) && te.result() != null) { JsonNode r = mapper.readTree(te.result()); String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index 90f9bd5..80061a8 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -1,6 +1,7 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.service.AuditService; import com.xly.service.ErpClient; import com.xly.service.OpService; @@ -31,11 +32,13 @@ public class OpController { private final OpService ops; private final ErpClient erp; private final AuditService audit; + private final ObjectMapper mapper; - public OpController(OpService ops, ErpClient erp, AuditService audit) { + public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) { this.ops = ops; this.erp = erp; this.audit = audit; + this.mapper = mapper; } /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ @@ -62,7 +65,11 @@ public class OpController { try { String opType = str(op.get("sOpType")); JsonNode r; - if ("delete".equals(opType)) { + if ("create".equals(opType)) { + @SuppressWarnings("unchecked") + Map columns = mapper.readValue(str(op.get("sPayload")), Map.class); + r = erp.createForm(str(op.get("sTargetTable")), columns); + } else if ("delete".equals(opType)) { r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); } else { r = erp.updateForm(