Commit e32a120571b24a931824aa8593525330938670bd

Authored by zichun
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).
src/main/java/com/xly/config/AgentConfig.java
@@ -2,6 +2,7 @@ package com.xly.config; @@ -2,6 +2,7 @@ package com.xly.config;
2 2
3 import com.xly.agent.ReActAgent; 3 import com.xly.agent.ReActAgent;
4 import com.xly.service.SystemPromptService; 4 import com.xly.service.SystemPromptService;
  5 +import com.xly.tool.ErpReadTool;
5 import com.xly.tool.KgQueryTool; 6 import com.xly.tool.KgQueryTool;
6 import dev.langchain4j.memory.chat.MessageWindowChatMemory; 7 import dev.langchain4j.memory.chat.MessageWindowChatMemory;
7 import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 8 import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
@@ -41,11 +42,13 @@ public class AgentConfig { @@ -41,11 +42,13 @@ public class AgentConfig {
41 } 42 }
42 43
43 @Bean 44 @Bean
44 - public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool) { 45 + public ReActAgent reActAgent(SystemPromptService systemPromptService,
  46 + KgQueryTool kgQueryTool,
  47 + ErpReadTool erpReadTool) {
45 String systemPrompt = systemPromptService.buildSystemPrompt(); 48 String systemPrompt = systemPromptService.buildSystemPrompt();
46 return AiServices.builder(ReActAgent.class) 49 return AiServices.builder(ReActAgent.class)
47 .streamingChatModel(agentStreamingModel()) 50 .streamingChatModel(agentStreamingModel())
48 - .tools(kgQueryTool) 51 + .tools(kgQueryTool, erpReadTool)
49 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) 52 .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20))
50 .systemMessageProvider(memoryId -> systemPrompt) 53 .systemMessageProvider(memoryId -> systemPrompt)
51 .build(); 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,14 +32,16 @@ public class SystemPromptService {
32 可能涉及哪些单据,再决定怎么做: 32 可能涉及哪些单据,再决定怎么做:
33 %s 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 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 42 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。
41 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 43 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。
42 - 4. 你**当前只有只读的「表单目录检索」能力**。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ 44 + 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\
43 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 45 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。
44 5. 信息不足时主动向用户提问,不要猜测。 46 5. 信息不足时主动向用户提问,不要猜测。
45 """.formatted(renderDomainMap()); 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,3 +20,16 @@ spring:
20 # macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp) 20 # macOS-friendly temp path (was D:/xlyweberp/ai/ocrtmp)
21 ocr: 21 ocr:
22 tmpPath: /Users/reporkey/Desktop/saas-8s+/tempPath/ocrtmp 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"