Commit 783cef5174e63fc24fba1d7fc5fd6ca5b6d3d8cb

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