Commit 61a2d2541fbe0129110e34e8359e3b96fb793bc7
1 parent
9bd3e8ea
feat: lookupRecord (KgSearch) + audit log + Query (safe read-only SQL)
- FormResolverService: shared KG resolution (entity->master table excl. viw_*, field 中文->column, name field, labels) - ErpReadTool.lookupRecord(entity, record): reliably resolves the master table + returns a single record's full labelled fields — fixes the 'specific field of a specific record' case (e.g. 必胜客的销售员 -> 马艺祖) - AuditService + ai_audit_log: immutable audit of propose/confirm/cancel/query (who/when/action/target/result) - QueryTool.queryData(question): read-only SQL escape hatch — coder-model NL2SQL grounded on 字段字典, jsqlparser single-SELECT guard, blocks OUTFILE/LOAD_FILE/information_schema/SLEEP, forced LIMIT, audited. SAFE by construction; NL2SQL table/join accuracy for complex questions is limited (architecture-flagged, deferred). Verified: lookupRecord works; audit rows written; Query guards + LIMIT + audit work (NL2SQL grounding still mis-picks entity master on multi-table questions).
Showing
9 changed files
with
442 additions
and
6 deletions
sql/ai_audit_log.sql
0 → 100644
| 1 | +-- ai_audit_log:AI 操作不可变审计(金融类数据合规要求)。 | ||
| 2 | +-- 记录谁、何时、什么动作、目标、结果。独立于 Langfuse(LLM tracing ≠ 业务审计)。 | ||
| 3 | +-- 只追加、不更新不删除(应用层保证;生产可再加触发器/权限强制)。 | ||
| 4 | +CREATE TABLE IF NOT EXISTS ai_audit_log ( | ||
| 5 | + sId bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, | ||
| 6 | + tTime datetime NOT NULL, | ||
| 7 | + sUserId varchar(64), | ||
| 8 | + sConversationId varchar(96), | ||
| 9 | + sAction varchar(24), -- propose | confirm | cancel | query | ||
| 10 | + sTarget varchar(200), -- 目标表/记录/操作简述 | ||
| 11 | + sDetail text, -- JSON 或 SQL 明细 | ||
| 12 | + sResult varchar(16), -- ok | fail | ||
| 13 | + sResultMsg varchar(500), | ||
| 14 | + KEY idx_time (tTime), | ||
| 15 | + KEY idx_user (sUserId, tTime) | ||
| 16 | +); |
src/main/java/com/xly/config/AgentConfig.java
| @@ -5,6 +5,7 @@ import com.xly.service.SystemPromptService; | @@ -5,6 +5,7 @@ import com.xly.service.SystemPromptService; | ||
| 5 | import com.xly.tool.ErpReadTool; | 5 | import com.xly.tool.ErpReadTool; |
| 6 | import com.xly.tool.KgQueryTool; | 6 | import com.xly.tool.KgQueryTool; |
| 7 | import com.xly.tool.ProposeWriteTool; | 7 | import com.xly.tool.ProposeWriteTool; |
| 8 | +import com.xly.tool.QueryTool; | ||
| 8 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; | 9 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; |
| 9 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; | 10 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; |
| 10 | import dev.langchain4j.service.AiServices; | 11 | import dev.langchain4j.service.AiServices; |
| @@ -50,11 +51,12 @@ public class AgentConfig { | @@ -50,11 +51,12 @@ public class AgentConfig { | ||
| 50 | KgQueryTool kgQueryTool, | 51 | KgQueryTool kgQueryTool, |
| 51 | ErpReadTool erpReadTool, | 52 | ErpReadTool erpReadTool, |
| 52 | ProposeWriteTool proposeWriteTool, | 53 | ProposeWriteTool proposeWriteTool, |
| 54 | + QueryTool queryTool, | ||
| 53 | RedisChatMemoryStore memoryStore) { | 55 | RedisChatMemoryStore memoryStore) { |
| 54 | String systemPrompt = systemPromptService.buildSystemPrompt(); | 56 | String systemPrompt = systemPromptService.buildSystemPrompt(); |
| 55 | return AiServices.builder(ReActAgent.class) | 57 | return AiServices.builder(ReActAgent.class) |
| 56 | .streamingChatModel(agentStreamingModel()) | 58 | .streamingChatModel(agentStreamingModel()) |
| 57 | - .tools(kgQueryTool, erpReadTool, proposeWriteTool) | 59 | + .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool) |
| 58 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | 60 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() |
| 59 | .id(memoryId) | 61 | .id(memoryId) |
| 60 | .maxMessages(30) | 62 | .maxMessages(30) |
src/main/java/com/xly/service/AuditService.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import org.slf4j.Logger; | ||
| 4 | +import org.slf4j.LoggerFactory; | ||
| 5 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 6 | +import org.springframework.stereotype.Service; | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * 业务审计:写操作 / SQL 的不可变留痕(谁、何时、什么动作、目标、结果)。 | ||
| 10 | + * 记账失败绝不阻断主流程(审计是旁路)。 | ||
| 11 | + */ | ||
| 12 | +@Service | ||
| 13 | +public class AuditService { | ||
| 14 | + | ||
| 15 | + private static final Logger log = LoggerFactory.getLogger(AuditService.class); | ||
| 16 | + | ||
| 17 | + private final JdbcTemplate jdbc; | ||
| 18 | + | ||
| 19 | + public AuditService(JdbcTemplate jdbc) { | ||
| 20 | + this.jdbc = jdbc; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + public void log(String userId, String conversationId, String action, | ||
| 24 | + String target, String detail, boolean ok, String resultMsg) { | ||
| 25 | + try { | ||
| 26 | + jdbc.update( | ||
| 27 | + "INSERT INTO ai_audit_log(tTime,sUserId,sConversationId,sAction,sTarget,sDetail,sResult,sResultMsg) " + | ||
| 28 | + "VALUES(NOW(),?,?,?,?,?,?,?)", | ||
| 29 | + userId, conversationId, action, trunc(target, 200), trunc(detail, 60000), | ||
| 30 | + ok ? "ok" : "fail", trunc(resultMsg, 500)); | ||
| 31 | + } catch (Exception e) { | ||
| 32 | + log.warn("audit write failed (action={}): {}", action, e.getMessage()); | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + private static String trunc(String s, int max) { | ||
| 37 | + if (s == null) { | ||
| 38 | + return null; | ||
| 39 | + } | ||
| 40 | + return s.length() > max ? s.substring(0, max) : s; | ||
| 41 | + } | ||
| 42 | +} |
src/main/java/com/xly/service/FormResolverService.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 4 | +import org.springframework.stereotype.Service; | ||
| 5 | + | ||
| 6 | +import java.util.HashMap; | ||
| 7 | +import java.util.List; | ||
| 8 | +import java.util.Map; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 表单 / 字段解析(KgSearch 内核)—— 把「实体类型 / 字段中文名」解析到具体的 | ||
| 12 | + * 主表 / formId / moduleId / 技术列名,供 Read 与 Write 共用,集中一处避免各工具各猜一套。 | ||
| 13 | + * | ||
| 14 | + * <p>数据全部来自已物化的知识图谱视图:{@code viw_ai_useful_forms} / {@code viw_kg_form} | ||
| 15 | + * / {@code viw_kg_field_dict}。 | ||
| 16 | + */ | ||
| 17 | +@Service | ||
| 18 | +public class FormResolverService { | ||
| 19 | + | ||
| 20 | + private final JdbcTemplate jdbc; | ||
| 21 | + | ||
| 22 | + public FormResolverService(JdbcTemplate jdbc) { | ||
| 23 | + this.jdbc = jdbc; | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + /** | ||
| 27 | + * 定位某实体的**可改主表**:该实体名下、table 类型、排除报表视图(viw_*)、按 AI工具/连接度/名称长度 | ||
| 28 | + * 取最常用的一张。返回含 sFormId / sModuleId / sDataSource 的行,找不到返回 null。 | ||
| 29 | + */ | ||
| 30 | + public Map<String, Object> resolveMasterForm(String entityKeyword) { | ||
| 31 | + if (isBlank(entityKeyword)) { | ||
| 32 | + return null; | ||
| 33 | + } | ||
| 34 | + try { | ||
| 35 | + List<Map<String, Object>> r = jdbc.queryForList( | ||
| 36 | + "SELECT af.sFormId, af.sModuleId, af.sDataSource, af.sFormTitle FROM viw_ai_useful_forms af " + | ||
| 37 | + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + | ||
| 38 | + "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' AND af.sDataSource NOT LIKE 'viw%' " + | ||
| 39 | + "ORDER BY COALESCE(f.bAiTool,0) DESC, " + | ||
| 40 | + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", | ||
| 41 | + "%" + entityKeyword.trim() + "%"); | ||
| 42 | + return r.isEmpty() ? null : r.get(0); | ||
| 43 | + } catch (Exception e) { | ||
| 44 | + return null; | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ | ||
| 49 | + public Map<String, String> fieldLabels(String formId) { | ||
| 50 | + Map<String, String> m = new HashMap<>(); | ||
| 51 | + try { | ||
| 52 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 53 | + "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh FROM viw_ai_useful_forms af " + | ||
| 54 | + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | ||
| 55 | + "WHERE af.sFormId = ? GROUP BY fd.sField", formId); | ||
| 56 | + for (Map<String, Object> r : rows) { | ||
| 57 | + Object f = r.get("f"); | ||
| 58 | + Object zh = r.get("zh"); | ||
| 59 | + if (f != null && zh != null) { | ||
| 60 | + m.put(f.toString(), zh.toString()); | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + } catch (Exception ignore) { | ||
| 64 | + } | ||
| 65 | + return m; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + /** 猜该表单/表的"名称"字段(以 Name 结尾、使用最广),用于按关键词过滤 / 展示记录名。 */ | ||
| 69 | + public String resolveNameField(String table) { | ||
| 70 | + return queryOne( | ||
| 71 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | ||
| 72 | + "ORDER BY iFormUses DESC LIMIT 1", table); | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + /** 字段中文名 -> 技术列名(先精确、再模糊)。 */ | ||
| 76 | + public String resolveField(String table, String fieldChinese) { | ||
| 77 | + String f = queryOne( | ||
| 78 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", | ||
| 79 | + table, fieldChinese.trim()); | ||
| 80 | + if (f == null) { | ||
| 81 | + f = queryOne( | ||
| 82 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", | ||
| 83 | + table, "%" + fieldChinese.trim() + "%"); | ||
| 84 | + } | ||
| 85 | + return f; | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + private String queryOne(String sql, Object... args) { | ||
| 89 | + try { | ||
| 90 | + List<Map<String, Object>> r = jdbc.queryForList(sql, args); | ||
| 91 | + if (!r.isEmpty()) { | ||
| 92 | + Object v = r.get(0).values().iterator().next(); | ||
| 93 | + return v == null ? null : v.toString(); | ||
| 94 | + } | ||
| 95 | + } catch (Exception ignore) { | ||
| 96 | + } | ||
| 97 | + return null; | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + private static boolean isBlank(String s) { | ||
| 101 | + return s == null || s.isBlank(); | ||
| 102 | + } | ||
| 103 | +} |
src/main/java/com/xly/service/OpService.java
| @@ -13,9 +13,11 @@ import java.util.Map; | @@ -13,9 +13,11 @@ import java.util.Map; | ||
| 13 | public class OpService { | 13 | public class OpService { |
| 14 | 14 | ||
| 15 | private final JdbcTemplate jdbc; | 15 | private final JdbcTemplate jdbc; |
| 16 | + private final AuditService audit; | ||
| 16 | 17 | ||
| 17 | - public OpService(JdbcTemplate jdbc) { | 18 | + public OpService(JdbcTemplate jdbc, AuditService audit) { |
| 18 | this.jdbc = jdbc; | 19 | this.jdbc = jdbc; |
| 20 | + this.audit = audit; | ||
| 19 | } | 21 | } |
| 20 | 22 | ||
| 21 | /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ | 23 | /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ |
| @@ -29,6 +31,7 @@ public class OpService { | @@ -29,6 +31,7 @@ public class OpService { | ||
| 29 | "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + | 31 | "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + |
| 30 | "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", | 32 | "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", |
| 31 | sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); | 33 | sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); |
| 34 | + audit.log(userId, null, "propose", table + "#" + billId, description, true, "draft staged (" + sId + ")"); | ||
| 32 | return sId; | 35 | return sId; |
| 33 | } | 36 | } |
| 34 | 37 |
src/main/java/com/xly/service/SystemPromptService.java
| @@ -35,8 +35,11 @@ public class SystemPromptService { | @@ -35,8 +35,11 @@ public class SystemPromptService { | ||
| 35 | %s | 35 | %s |
| 36 | 【可用工具】 | 36 | 【可用工具】 |
| 37 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 | 37 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 38 | - - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ | ||
| 39 | - keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 | 38 | + - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 |
| 39 | + - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ | ||
| 40 | + 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 | ||
| 41 | + - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ | ||
| 42 | + 仅当 readFormData / lookupRecord 都答不了时才用。 | ||
| 40 | - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ | 43 | - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ |
| 41 | (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 | 44 | (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 |
| 42 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ | 45 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
src/main/java/com/xly/tool/ErpReadTool.java
| @@ -2,6 +2,7 @@ package com.xly.tool; | @@ -2,6 +2,7 @@ package com.xly.tool; | ||
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | import com.xly.service.ErpClient; | 4 | import com.xly.service.ErpClient; |
| 5 | +import com.xly.service.FormResolverService; | ||
| 5 | import dev.langchain4j.agent.tool.P; | 6 | import dev.langchain4j.agent.tool.P; |
| 6 | import dev.langchain4j.agent.tool.Tool; | 7 | import dev.langchain4j.agent.tool.Tool; |
| 7 | import org.springframework.jdbc.core.JdbcTemplate; | 8 | import org.springframework.jdbc.core.JdbcTemplate; |
| @@ -31,10 +32,80 @@ public class ErpReadTool { | @@ -31,10 +32,80 @@ public class ErpReadTool { | ||
| 31 | 32 | ||
| 32 | private final ErpClient erp; | 33 | private final ErpClient erp; |
| 33 | private final JdbcTemplate jdbc; | 34 | private final JdbcTemplate jdbc; |
| 35 | + private final FormResolverService resolver; | ||
| 34 | 36 | ||
| 35 | - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc) { | 37 | + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver) { |
| 36 | this.erp = erp; | 38 | this.erp = erp; |
| 37 | this.jdbc = jdbc; | 39 | this.jdbc = jdbc; |
| 40 | + this.resolver = resolver; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" | ||
| 44 | + + "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。") | ||
| 45 | + public String lookupRecord( | ||
| 46 | + @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, | ||
| 47 | + @P("记录名称关键词,如某个客户名") String recordKeyword) { | ||
| 48 | + if (entityKeyword == null || entityKeyword.isBlank() || recordKeyword == null || recordKeyword.isBlank()) { | ||
| 49 | + return "请提供实体类型和记录名称关键词。"; | ||
| 50 | + } | ||
| 51 | + Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); | ||
| 52 | + if (form == null) { | ||
| 53 | + return "找不到「" + entityKeyword + "」对应的主表。"; | ||
| 54 | + } | ||
| 55 | + String formId = String.valueOf(form.get("sFormId")); | ||
| 56 | + String moduleId = String.valueOf(form.get("sModuleId")); | ||
| 57 | + String table = String.valueOf(form.get("sDataSource")); | ||
| 58 | + String nameField = resolver.resolveNameField(table); | ||
| 59 | + | ||
| 60 | + JsonNode root; | ||
| 61 | + try { | ||
| 62 | + root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim()); | ||
| 63 | + } catch (Exception e) { | ||
| 64 | + return "读取失败:" + e.getMessage(); | ||
| 65 | + } | ||
| 66 | + if (root.path("code").asInt(0) < 0) { | ||
| 67 | + return "读取失败:" + root.path("msg").asText("未知错误"); | ||
| 68 | + } | ||
| 69 | + JsonNode rows = root.path("dataset").path("rows"); | ||
| 70 | + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | ||
| 71 | + int n = (data != null && data.isArray()) ? data.size() : 0; | ||
| 72 | + if (n == 0) { | ||
| 73 | + return "没有找到名称含「" + recordKeyword + "」的记录。"; | ||
| 74 | + } | ||
| 75 | + if (n > 1) { | ||
| 76 | + StringBuilder names = new StringBuilder(); | ||
| 77 | + for (int i = 0; i < data.size() && i < 5; i++) { | ||
| 78 | + if (i > 0) names.append("、"); | ||
| 79 | + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); | ||
| 80 | + } | ||
| 81 | + return "匹配到多条(" + names + "),请说得更具体一点。"; | ||
| 82 | + } | ||
| 83 | + return renderRecord(data.get(0), resolver.fieldLabels(formId)); | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + /** 把一条记录渲染成「中文名:值」清单(跳过空值、id/token/布尔噪声),最多 20 行。 */ | ||
| 87 | + private String renderRecord(JsonNode rec, Map<String, String> labels) { | ||
| 88 | + StringBuilder sb = new StringBuilder(); | ||
| 89 | + int count = 0; | ||
| 90 | + Iterator<String> it = rec.fieldNames(); | ||
| 91 | + while (it.hasNext() && count < 20) { | ||
| 92 | + String c = it.next(); | ||
| 93 | + if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { | ||
| 94 | + continue; | ||
| 95 | + } | ||
| 96 | + JsonNode v = rec.path(c); | ||
| 97 | + if (v.isContainerNode() || v.isNull()) { | ||
| 98 | + continue; | ||
| 99 | + } | ||
| 100 | + String val = v.asText("").trim(); | ||
| 101 | + String label = labels.get(c); | ||
| 102 | + if (val.isEmpty() || label == null) { | ||
| 103 | + continue; | ||
| 104 | + } | ||
| 105 | + sb.append("- ").append(label).append(":").append(val.length() > 60 ? val.substring(0, 60) + "…" : val).append("\n"); | ||
| 106 | + count++; | ||
| 107 | + } | ||
| 108 | + return sb.length() == 0 ? "找到了该记录,但没有可展示的字段。" : sb.toString(); | ||
| 38 | } | 109 | } |
| 39 | 110 | ||
| 40 | @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" | 111 | @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" |
src/main/java/com/xly/tool/QueryTool.java
0 → 100644
| 1 | +package com.xly.tool; | ||
| 2 | + | ||
| 3 | +import com.xly.service.AuditService; | ||
| 4 | +import dev.langchain4j.agent.tool.P; | ||
| 5 | +import dev.langchain4j.agent.tool.Tool; | ||
| 6 | +import dev.langchain4j.model.ollama.OllamaChatModel; | ||
| 7 | +import net.sf.jsqlparser.parser.CCJSqlParserUtil; | ||
| 8 | +import net.sf.jsqlparser.statement.Statement; | ||
| 9 | +import net.sf.jsqlparser.statement.select.Select; | ||
| 10 | +import org.springframework.beans.factory.annotation.Qualifier; | ||
| 11 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 12 | +import org.springframework.stereotype.Component; | ||
| 13 | + | ||
| 14 | +import java.util.List; | ||
| 15 | +import java.util.Map; | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题 | ||
| 19 | + * (跨表汇总、按条件计数排名等)。 | ||
| 20 | + * | ||
| 21 | + * <p>安全栈:用 coder 模型据 KG 字段字典接地生成 SQL → jsqlparser 强制**单条 SELECT** → | ||
| 22 | + * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → | ||
| 23 | + * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 | ||
| 24 | + */ | ||
| 25 | +@Component | ||
| 26 | +public class QueryTool { | ||
| 27 | + | ||
| 28 | + private final OllamaChatModel sqlModel; | ||
| 29 | + private final JdbcTemplate jdbc; | ||
| 30 | + private final AuditService audit; | ||
| 31 | + | ||
| 32 | + public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) { | ||
| 33 | + this.sqlModel = sqlModel; | ||
| 34 | + this.jdbc = jdbc; | ||
| 35 | + this.audit = audit; | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" | ||
| 39 | + + "(如跨表汇总、按条件计数、排名、分组统计)。仅在 readFormData / lookupRecord 无法回答时才用。") | ||
| 40 | + public String queryData(@P("用自然语言描述要统计/分析什么") String question) { | ||
| 41 | + if (question == null || question.isBlank()) { | ||
| 42 | + return "请描述要查询统计的内容。"; | ||
| 43 | + } | ||
| 44 | + String hint = schemaHint(question); | ||
| 45 | + String prompt = """ | ||
| 46 | + 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。 | ||
| 47 | + 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名): | ||
| 48 | + %s | ||
| 49 | + 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100); | ||
| 50 | + **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**; | ||
| 51 | + 表名、列名一律用上面给定的英文名。**只输出 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); | ||
| 68 | + | ||
| 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(); | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + /** 据字段字典把问题里出现的中文术语接地到具体表+列,喂给 coder 模型。 */ | ||
| 80 | + private String schemaHint(String question) { | ||
| 81 | + StringBuilder sb = new StringBuilder(); | ||
| 82 | + try { | ||
| 83 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 84 | + "SELECT fd.sTable, " + | ||
| 85 | + "GROUP_CONCAT(DISTINCT CONCAT(fd.sField,'=',fd.sChinese) ORDER BY fd.iFormUses DESC SEPARATOR ', ') cols, " + | ||
| 86 | + "SUM(fd.iFormUses) usage_ " + | ||
| 87 | + "FROM viw_kg_field_dict fd " + | ||
| 88 | + "WHERE CHAR_LENGTH(fd.sChinese)>=2 AND INSTR(?, fd.sChinese)>0 " + | ||
| 89 | + "AND fd.sTable NOT LIKE 'viw%' " + | ||
| 90 | + "AND fd.sTable IN (SELECT DISTINCT sDataSource FROM viw_ai_useful_forms) " + | ||
| 91 | + "GROUP BY fd.sTable ORDER BY usage_ DESC, COUNT(*) DESC LIMIT 6", question); | ||
| 92 | + for (Map<String, Object> r : rows) { | ||
| 93 | + String cols = String.valueOf(r.get("cols")); | ||
| 94 | + if (cols.length() > 400) { | ||
| 95 | + cols = cols.substring(0, 400) + "…"; | ||
| 96 | + } | ||
| 97 | + sb.append("- ").append(r.get("sTable")).append("(").append(cols).append(")\n"); | ||
| 98 | + } | ||
| 99 | + } catch (Exception ignore) { | ||
| 100 | + } | ||
| 101 | + if (sb.length() == 0) { | ||
| 102 | + sb.append("(未匹配到具体表;请在问题里使用业务术语,如 客户 / 订单 / 金额 / 数量)\n"); | ||
| 103 | + } | ||
| 104 | + return sb.toString(); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + private String cleanSql(String raw) { | ||
| 108 | + if (raw == null) { | ||
| 109 | + return ""; | ||
| 110 | + } | ||
| 111 | + String s = raw.replace("```sql", "").replace("```", "").trim(); | ||
| 112 | + int i = s.toLowerCase().indexOf("select"); | ||
| 113 | + if (i > 0) { | ||
| 114 | + s = s.substring(i); | ||
| 115 | + } | ||
| 116 | + int semi = s.indexOf(';'); | ||
| 117 | + if (semi >= 0) { | ||
| 118 | + s = s.substring(0, semi); | ||
| 119 | + } | ||
| 120 | + return s.trim(); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + /** 单条 SELECT + 挡危险构造。返回 null=通过,否则=拒绝原因。 */ | ||
| 124 | + private String validate(String sql) { | ||
| 125 | + if (sql == null || sql.isBlank()) { | ||
| 126 | + return "未生成SQL"; | ||
| 127 | + } | ||
| 128 | + String low = sql.toLowerCase(); | ||
| 129 | + String[] bad = {"into outfile", "into dumpfile", "load_file", "load data", | ||
| 130 | + "information_schema", "sleep(", "benchmark(", "sys.", "mysql."}; | ||
| 131 | + for (String b : bad) { | ||
| 132 | + if (low.contains(b)) { | ||
| 133 | + return "含禁止构造: " + b; | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + try { | ||
| 137 | + Statement stmt = CCJSqlParserUtil.parse(sql); | ||
| 138 | + if (!(stmt instanceof Select)) { | ||
| 139 | + return "只允许 SELECT"; | ||
| 140 | + } | ||
| 141 | + } catch (Exception e) { | ||
| 142 | + return "SQL 解析失败"; | ||
| 143 | + } | ||
| 144 | + return null; | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + private String forceLimit(String sql) { | ||
| 148 | + String low = sql.toLowerCase(); | ||
| 149 | + if (!low.matches("(?s).*\\blimit\\b.*")) { | ||
| 150 | + return sql.trim() + " LIMIT 100"; | ||
| 151 | + } | ||
| 152 | + return sql; | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + private String formatRows(List<Map<String, Object>> rows) { | ||
| 156 | + if (rows.isEmpty()) { | ||
| 157 | + return "查询完成,没有匹配的数据。"; | ||
| 158 | + } | ||
| 159 | + List<String> cols = List.copyOf(rows.get(0).keySet()); | ||
| 160 | + StringBuilder sb = new StringBuilder(); | ||
| 161 | + sb.append("查询结果(").append(rows.size()).append(" 行):\n\n"); | ||
| 162 | + sb.append("| ").append(String.join(" | ", cols)).append(" |\n"); | ||
| 163 | + sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); | ||
| 164 | + int shown = 0; | ||
| 165 | + for (Map<String, Object> r : rows) { | ||
| 166 | + if (shown++ >= 30) { | ||
| 167 | + sb.append("| … 仅显示前 30 行 |").append(" |".repeat(Math.max(0, cols.size() - 1))).append("\n"); | ||
| 168 | + break; | ||
| 169 | + } | ||
| 170 | + StringBuilder line = new StringBuilder("| "); | ||
| 171 | + for (String c : cols) { | ||
| 172 | + Object v = r.get(c); | ||
| 173 | + String s = v == null ? "" : v.toString().replace("|", "/").replace("\n", " "); | ||
| 174 | + if (s.length() > 30) { | ||
| 175 | + s = s.substring(0, 30) + "…"; | ||
| 176 | + } | ||
| 177 | + line.append(s).append(" | "); | ||
| 178 | + } | ||
| 179 | + sb.append(line).append("\n"); | ||
| 180 | + } | ||
| 181 | + return sb.toString(); | ||
| 182 | + } | ||
| 183 | +} |
src/main/java/com/xly/web/OpController.java
| 1 | package com.xly.web; | 1 | package com.xly.web; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | +import com.xly.service.AuditService; | ||
| 4 | import com.xly.service.ErpClient; | 5 | import com.xly.service.ErpClient; |
| 5 | import com.xly.service.OpService; | 6 | import com.xly.service.OpService; |
| 6 | import org.slf4j.Logger; | 7 | import org.slf4j.Logger; |
| @@ -29,10 +30,12 @@ public class OpController { | @@ -29,10 +30,12 @@ public class OpController { | ||
| 29 | 30 | ||
| 30 | private final OpService ops; | 31 | private final OpService ops; |
| 31 | private final ErpClient erp; | 32 | private final ErpClient erp; |
| 33 | + private final AuditService audit; | ||
| 32 | 34 | ||
| 33 | - public OpController(OpService ops, ErpClient erp) { | 35 | + public OpController(OpService ops, ErpClient erp, AuditService audit) { |
| 34 | this.ops = ops; | 36 | this.ops = ops; |
| 35 | this.erp = erp; | 37 | this.erp = erp; |
| 38 | + this.audit = audit; | ||
| 36 | } | 39 | } |
| 37 | 40 | ||
| 38 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ | 41 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ |
| @@ -52,6 +55,10 @@ public class OpController { | @@ -52,6 +55,10 @@ public class OpController { | ||
| 52 | if (!"draft".equals(String.valueOf(op.get("sStatus")))) { | 55 | if (!"draft".equals(String.valueOf(op.get("sStatus")))) { |
| 53 | return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); | 56 | return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); |
| 54 | } | 57 | } |
| 58 | + String uid = str(op.get("sUserId")); | ||
| 59 | + String conv = str(op.get("sConversationId")); | ||
| 60 | + String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")); | ||
| 61 | + String detail = str(op.get("sDescription")); | ||
| 55 | try { | 62 | try { |
| 56 | JsonNode r = erp.updateForm( | 63 | JsonNode r = erp.updateForm( |
| 57 | str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), | 64 | str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), |
| @@ -59,14 +66,17 @@ public class OpController { | @@ -59,14 +66,17 @@ public class OpController { | ||
| 59 | int code = r.path("code").asInt(0); | 66 | int code = r.path("code").asInt(0); |
| 60 | if (code == 1) { | 67 | if (code == 1) { |
| 61 | ops.setStatus(id, "executed", "操作成功"); | 68 | ops.setStatus(id, "executed", "操作成功"); |
| 69 | + audit.log(uid, conv, "confirm", target, detail, true, "executed"); | ||
| 62 | return result("executed", "已修改:" + op.get("sDescription"), op); | 70 | return result("executed", "已修改:" + op.get("sDescription"), op); |
| 63 | } | 71 | } |
| 64 | String msg = r.path("msg").asText("执行失败"); | 72 | String msg = r.path("msg").asText("执行失败"); |
| 65 | ops.setStatus(id, "failed", msg); | 73 | ops.setStatus(id, "failed", msg); |
| 74 | + audit.log(uid, conv, "confirm", target, detail, false, msg); | ||
| 66 | return result("failed", msg, op); | 75 | return result("failed", msg, op); |
| 67 | } catch (Exception e) { | 76 | } catch (Exception e) { |
| 68 | log.warn("confirm op {} failed", id, e); | 77 | log.warn("confirm op {} failed", id, e); |
| 69 | ops.setStatus(id, "failed", e.getMessage()); | 78 | ops.setStatus(id, "failed", e.getMessage()); |
| 79 | + audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); | ||
| 70 | return result("failed", "执行异常:" + e.getMessage(), op); | 80 | return result("failed", "执行异常:" + e.getMessage(), op); |
| 71 | } | 81 | } |
| 72 | } | 82 | } |
| @@ -77,6 +87,9 @@ public class OpController { | @@ -77,6 +87,9 @@ public class OpController { | ||
| 77 | Map<String, Object> op = ops.get(id); | 87 | Map<String, Object> op = ops.get(id); |
| 78 | if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { | 88 | if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { |
| 79 | ops.setStatus(id, "cancelled", "用户取消"); | 89 | ops.setStatus(id, "cancelled", "用户取消"); |
| 90 | + audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", | ||
| 91 | + str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), | ||
| 92 | + str(op.get("sDescription")), true, "cancelled"); | ||
| 80 | } | 93 | } |
| 81 | return result("cancelled", "已取消", op); | 94 | return result("cancelled", "已取消", op); |
| 82 | } | 95 | } |