Commit e32a120571b24a931824aa8593525330938670bd
1 parent
d9117ce8
feat(A): real ERP data reads (Read tool via form API)
- ErpClient: thin HTTP client to ERP xlyEntry; dev-login (/checklogin admin) mints+caches a real session token, auto re-login on session expiry (code=-2); reads getBusinessDataByFormcustomId (read-only params only, never bUpdate) - ErpReadTool.readFormData(formId,moduleId): reads a form's real rows, renders Chinese column labels from viw_kg_field_dict, first 10 rows + total count - wired into the agent (tools = findForms + readFormData); system prompt now describes the findForms->readFormData flow (still read-only, writes still 'under development') - application-saaslocal.yml: erp.baseurl -> local xlyEntry :8697 + dev-login creds Verified: '我们有多少客户' -> agent finds the form, reads 92 real customers, renders a clean Chinese table (name/category/salesperson/tax).
Showing
5 changed files
with
301 additions
and
6 deletions
src/main/java/com/xly/config/AgentConfig.java
| ... | ... | @@ -2,6 +2,7 @@ package com.xly.config; |
| 2 | 2 | |
| 3 | 3 | import com.xly.agent.ReActAgent; |
| 4 | 4 | import com.xly.service.SystemPromptService; |
| 5 | +import com.xly.tool.ErpReadTool; | |
| 5 | 6 | import com.xly.tool.KgQueryTool; |
| 6 | 7 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; |
| 7 | 8 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; |
| ... | ... | @@ -41,11 +42,13 @@ public class AgentConfig { |
| 41 | 42 | } |
| 42 | 43 | |
| 43 | 44 | @Bean |
| 44 | - public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool) { | |
| 45 | + public ReActAgent reActAgent(SystemPromptService systemPromptService, | |
| 46 | + KgQueryTool kgQueryTool, | |
| 47 | + ErpReadTool erpReadTool) { | |
| 45 | 48 | String systemPrompt = systemPromptService.buildSystemPrompt(); |
| 46 | 49 | return AiServices.builder(ReActAgent.class) |
| 47 | 50 | .streamingChatModel(agentStreamingModel()) |
| 48 | - .tools(kgQueryTool) | |
| 51 | + .tools(kgQueryTool, erpReadTool) | |
| 49 | 52 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) |
| 50 | 53 | .systemMessageProvider(memoryId -> systemPrompt) |
| 51 | 54 | .build(); | ... | ... |
src/main/java/com/xly/service/ErpClient.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import org.slf4j.Logger; | |
| 6 | +import org.slf4j.LoggerFactory; | |
| 7 | +import org.springframework.beans.factory.annotation.Value; | |
| 8 | +import org.springframework.stereotype.Service; | |
| 9 | + | |
| 10 | +import java.net.URI; | |
| 11 | +import java.net.http.HttpClient; | |
| 12 | +import java.net.http.HttpRequest; | |
| 13 | +import java.net.http.HttpResponse; | |
| 14 | +import java.nio.charset.StandardCharsets; | |
| 15 | +import java.time.Duration; | |
| 16 | +import java.util.List; | |
| 17 | +import java.util.Map; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * ERP 后端(xlyEntry)薄 HTTP 客户端。 | |
| 21 | + * | |
| 22 | + * <p>xlyAi 的读写业务逻辑在 ERP 后端,xlyAi 只是像前端一样发同样的 API 请求。本类负责: | |
| 23 | + * <ul> | |
| 24 | + * <li>取得可用的 ERP 会话 token —— 本地开发用配置的 dev 账号 {@code /checklogin} 登录并缓存; | |
| 25 | + * 生产应改为透传用户浏览器里的 ERP 登录 token(per-request,never stored)。</li> | |
| 26 | + * <li>调用通用表单读接口 {@code getBusinessDataByFormcustomId};会话过期(code=-2)自动重登一次重试。</li> | |
| 27 | + * </ul> | |
| 28 | + */ | |
| 29 | +@Service | |
| 30 | +public class ErpClient { | |
| 31 | + | |
| 32 | + private static final Logger log = LoggerFactory.getLogger(ErpClient.class); | |
| 33 | + | |
| 34 | + private final ObjectMapper mapper; | |
| 35 | + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); | |
| 36 | + | |
| 37 | + @Value("${erp.baseurl}") | |
| 38 | + private String baseUrl; | |
| 39 | + @Value("${erp.dev-login.brand:1111111111}") | |
| 40 | + private String brand; | |
| 41 | + @Value("${erp.dev-login.subsidiary:1111111111}") | |
| 42 | + private String subsidiary; | |
| 43 | + @Value("${erp.dev-login.username:admin}") | |
| 44 | + private String username; | |
| 45 | + @Value("${erp.dev-login.password:666666}") | |
| 46 | + private String password; | |
| 47 | + | |
| 48 | + private volatile String cachedToken; | |
| 49 | + | |
| 50 | + public ErpClient(ObjectMapper mapper) { | |
| 51 | + this.mapper = mapper; | |
| 52 | + } | |
| 53 | + | |
| 54 | + /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。 */ | |
| 55 | + private synchronized String login() { | |
| 56 | + try { | |
| 57 | + String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary; | |
| 58 | + String body = mapper.writeValueAsString(Map.of("username", username, "password", password)); | |
| 59 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | |
| 60 | + .header("Content-Type", "application/json;charset=UTF-8") | |
| 61 | + .timeout(Duration.ofSeconds(20)) | |
| 62 | + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) | |
| 63 | + .build(); | |
| 64 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 65 | + JsonNode root = mapper.readTree(resp.body()); | |
| 66 | + if (root.path("code").asInt() != 1) { | |
| 67 | + throw new IllegalStateException("ERP 登录失败: " + root.path("msg").asText()); | |
| 68 | + } | |
| 69 | + String token = root.path("dataset").path("rows").path(0).path("token").asText(null); | |
| 70 | + if (token == null || token.isBlank()) { | |
| 71 | + throw new IllegalStateException("ERP 登录未返回 token"); | |
| 72 | + } | |
| 73 | + cachedToken = token; | |
| 74 | + log.info("ERP dev-login ok (user={}), token cached", username); | |
| 75 | + return token; | |
| 76 | + } catch (Exception e) { | |
| 77 | + throw new RuntimeException("ERP 登录异常: " + e.getMessage(), e); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + private String token() { | |
| 82 | + String t = cachedToken; | |
| 83 | + return (t != null && !t.isBlank()) ? t : login(); | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 | |
| 88 | + * 会话过期(code=-2)时自动重登一次并重试。 | |
| 89 | + */ | |
| 90 | + public JsonNode readForm(String formId, String moduleId, int page, int pageSize) { | |
| 91 | + JsonNode root = doRead(formId, moduleId, page, pageSize, token()); | |
| 92 | + if (root.path("code").asInt() == -2) { | |
| 93 | + login(); | |
| 94 | + root = doRead(formId, moduleId, page, pageSize, token()); | |
| 95 | + } | |
| 96 | + return root; | |
| 97 | + } | |
| 98 | + | |
| 99 | + private JsonNode doRead(String formId, String moduleId, int page, int pageSize, String tok) { | |
| 100 | + try { | |
| 101 | + String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId | |
| 102 | + + "?sModelsId=" + moduleId + "&sName="; | |
| 103 | + String body = mapper.writeValueAsString(Map.of( | |
| 104 | + "pageNum", page, "pageSize", pageSize, "bFilter", List.of())); | |
| 105 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | |
| 106 | + .header("Content-Type", "application/json;charset=UTF-8") | |
| 107 | + .header("Authorization", tok) | |
| 108 | + .timeout(Duration.ofSeconds(30)) | |
| 109 | + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) | |
| 110 | + .build(); | |
| 111 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 112 | + return mapper.readTree(resp.body()); | |
| 113 | + } catch (Exception e) { | |
| 114 | + throw new RuntimeException("ERP 读取异常: " + e.getMessage(), e); | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -32,14 +32,16 @@ public class SystemPromptService { |
| 32 | 32 | 可能涉及哪些单据,再决定怎么做: |
| 33 | 33 | %s |
| 34 | 34 | 【可用工具】 |
| 35 | - - findForms(keyword):按关键词检索业务表单目录,用来把用户口中的「单据 / 报表」定位到具体是哪一张表单\ | |
| 36 | - (拿到 formId / moduleId)。当你不确定用户指的到底是哪张表单时,先调用它确认,不要自己编表单名。 | |
| 35 | + - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 | |
| 36 | + - readFormData(formId, moduleId):读取该表单的真实业务数据(返回前若干行 + 总条数)。 | |
| 37 | + 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ | |
| 38 | + (可小结总条数、列出前几条)。绝不自己编表单名或数据。 | |
| 37 | 39 | |
| 38 | 40 | 【行为准则】 |
| 39 | - 1. 凡是能用工具确认的事实(表单、单据、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | |
| 41 | + 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 | |
| 40 | 42 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 |
| 41 | 43 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 42 | - 4. 你**当前只有只读的「表单目录检索」能力**。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ | |
| 44 | + 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ | |
| 43 | 45 | 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 |
| 44 | 46 | 5. 信息不足时主动向用户提问,不要猜测。 |
| 45 | 47 | """.formatted(renderDomainMap()); | ... | ... |
src/main/java/com/xly/tool/ErpReadTool.java
0 → 100644
| 1 | +package com.xly.tool; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.xly.service.ErpClient; | |
| 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.HashMap; | |
| 12 | +import java.util.Iterator; | |
| 13 | +import java.util.List; | |
| 14 | +import java.util.Map; | |
| 15 | +import java.util.stream.Collectors; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Read 工具(Phase A):读取指定 ERP 表单的真实业务数据。 | |
| 19 | + * | |
| 20 | + * <p>走 ERP 后端通用表单读接口({@link ErpClient})——即架构里"Read = 薄 HTTP 客户端、 | |
| 21 | + * 业务逻辑在 ERP 后端、得到租户+行级范围"。列名用字段字典({@code viw_kg_field_dict}) | |
| 22 | + * 渲染成中文,只展示前几列/前几行,避免吐爆上下文。 | |
| 23 | + * | |
| 24 | + * <p>安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 | |
| 25 | + */ | |
| 26 | +@Component | |
| 27 | +public class ErpReadTool { | |
| 28 | + | |
| 29 | + private static final int MAX_ROWS = 10; | |
| 30 | + private static final int MAX_COLS = 6; | |
| 31 | + | |
| 32 | + private final ErpClient erp; | |
| 33 | + private final JdbcTemplate jdbc; | |
| 34 | + | |
| 35 | + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc) { | |
| 36 | + this.erp = erp; | |
| 37 | + this.jdbc = jdbc; | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。" | |
| 41 | + + "必须先用 findForms 得到目标表单的 formId 与 moduleId,再调用本工具。") | |
| 42 | + public String readFormData( | |
| 43 | + @P("表单id(findForms 返回的 formId)") String formId, | |
| 44 | + @P("菜单id(findForms 返回的 moduleId)") String moduleId) { | |
| 45 | + | |
| 46 | + if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { | |
| 47 | + return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; | |
| 48 | + } | |
| 49 | + | |
| 50 | + JsonNode root; | |
| 51 | + try { | |
| 52 | + root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS); | |
| 53 | + } catch (Exception e) { | |
| 54 | + return "读取失败:" + e.getMessage(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + int code = root.path("code").asInt(0); | |
| 58 | + if (code < 0) { | |
| 59 | + return "读取失败:" + root.path("msg").asText("未知错误"); | |
| 60 | + } | |
| 61 | + | |
| 62 | + JsonNode ds = root.path("dataset"); | |
| 63 | + int total = ds.path("totalCount").asInt(0); | |
| 64 | + JsonNode rows = ds.path("rows"); | |
| 65 | + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | |
| 66 | + if (data == null || !data.isArray() || data.isEmpty()) { | |
| 67 | + return "该表单当前没有数据(共 " + total + " 条)。"; | |
| 68 | + } | |
| 69 | + | |
| 70 | + Map<String, String> labels = loadLabels(formId.trim()); | |
| 71 | + List<String> cols = pickColumns(data.get(0), labels); | |
| 72 | + | |
| 73 | + StringBuilder sb = new StringBuilder(); | |
| 74 | + sb.append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); | |
| 75 | + sb.append("| ") | |
| 76 | + .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) | |
| 77 | + .append(" |\n"); | |
| 78 | + sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); | |
| 79 | + for (JsonNode r : data) { | |
| 80 | + List<String> vals = new ArrayList<>(cols.size()); | |
| 81 | + for (String c : cols) { | |
| 82 | + vals.add(cell(r.path(c))); | |
| 83 | + } | |
| 84 | + sb.append("| ").append(String.join(" | ", vals)).append(" |\n"); | |
| 85 | + } | |
| 86 | + return sb.toString(); | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** 表单数据源表 -> 字段中文名(字段字典),用于把技术列名渲染为中文表头。 */ | |
| 90 | + private Map<String, String> loadLabels(String formId) { | |
| 91 | + Map<String, String> m = new HashMap<>(); | |
| 92 | + try { | |
| 93 | + List<Map<String, Object>> rows = jdbc.queryForList( | |
| 94 | + "SELECT fd.sField AS f, MIN(fd.sChinese) AS zh " + | |
| 95 | + "FROM viw_ai_useful_forms af " + | |
| 96 | + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | |
| 97 | + "WHERE af.sFormId = ? GROUP BY fd.sField", formId); | |
| 98 | + for (Map<String, Object> r : rows) { | |
| 99 | + Object f = r.get("f"); | |
| 100 | + Object zh = r.get("zh"); | |
| 101 | + if (f != null && zh != null) { | |
| 102 | + m.put(f.toString(), zh.toString()); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } catch (Exception ignore) { | |
| 106 | + // 无字典时退化为技术列名 | |
| 107 | + } | |
| 108 | + return m; | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ | |
| 112 | + private List<String> pickColumns(JsonNode first, Map<String, String> labels) { | |
| 113 | + List<String> labeled = new ArrayList<>(); | |
| 114 | + List<String> others = new ArrayList<>(); | |
| 115 | + Iterator<String> it = first.fieldNames(); | |
| 116 | + while (it.hasNext()) { | |
| 117 | + String c = it.next(); | |
| 118 | + if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { | |
| 119 | + continue; // 跳过 id / token / 布尔字段 | |
| 120 | + } | |
| 121 | + JsonNode v = first.path(c); | |
| 122 | + if (v.isContainerNode()) { | |
| 123 | + continue; | |
| 124 | + } | |
| 125 | + if (labels.containsKey(c)) { | |
| 126 | + labeled.add(c); | |
| 127 | + } else { | |
| 128 | + others.add(c); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + List<String> cols = new ArrayList<>(labeled); | |
| 132 | + for (String o : others) { | |
| 133 | + if (cols.size() >= MAX_COLS) { | |
| 134 | + break; | |
| 135 | + } | |
| 136 | + cols.add(o); | |
| 137 | + } | |
| 138 | + if (cols.size() > MAX_COLS) { | |
| 139 | + cols = new ArrayList<>(cols.subList(0, MAX_COLS)); | |
| 140 | + } | |
| 141 | + if (cols.isEmpty()) { | |
| 142 | + Iterator<String> it2 = first.fieldNames(); | |
| 143 | + while (it2.hasNext() && cols.size() < 4) { | |
| 144 | + String c = it2.next(); | |
| 145 | + if (!first.path(c).isContainerNode()) { | |
| 146 | + cols.add(c); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + return cols; | |
| 151 | + } | |
| 152 | + | |
| 153 | + private String cell(JsonNode v) { | |
| 154 | + if (v == null || v.isNull() || v.isMissingNode()) { | |
| 155 | + return ""; | |
| 156 | + } | |
| 157 | + String s = v.asText("").replace("|", "/").replace("\n", " ").trim(); | |
| 158 | + return s.length() > 24 ? s.substring(0, 24) + "…" : s; | |
| 159 | + } | |
| 160 | +} | ... | ... |
src/main/resources/application-saaslocal.yml
| ... | ... | @@ -20,3 +20,16 @@ spring: |
| 20 | 20 | # macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp) |
| 21 | 21 | ocr: |
| 22 | 22 | tmpPath: /Users/reporkey/Desktop/saas-8s+/tempPath/ocrtmp |
| 23 | + | |
| 24 | +# Point ERP form-read API at the LOCAL xlyEntry (:8697, ctx /xlyEntry) which uses the | |
| 25 | +# same local DB. The committed application.yml erp.baseurl targets the remote deploy. | |
| 26 | +# dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); | |
| 27 | +# production should instead pass through the user's own browser ERP token per request. | |
| 28 | +erp: | |
| 29 | + baseurl: http://127.0.0.1:8697/xlyEntry | |
| 30 | + dev-login: | |
| 31 | + enabled: true | |
| 32 | + brand: "1111111111" | |
| 33 | + subsidiary: "1111111111" | |
| 34 | + username: admin | |
| 35 | + password: "666666" | ... | ... |