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