From 2cef96e4aa774b3504e2046e6b70cce0578f25ec Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:07:33 +0800 Subject: [PATCH] feat(C): read robustness + model reliability --- src/main/java/com/xly/config/AgentConfig.java | 3 +++ src/main/java/com/xly/service/ErpClient.java | 22 +++++++++++++++++----- src/main/java/com/xly/service/SystemPromptService.java | 11 ++++++++--- src/main/java/com/xly/tool/ErpReadTool.java | 40 +++++++++++++++++++++++++++++++++++----- src/main/java/com/xly/tool/KgQueryTool.java | 12 +++++++----- src/main/resources/application-saaslocal.yml | 6 ++++++ 6 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 64369da..9fe08a8 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -37,6 +37,9 @@ public class AgentConfig { .temperature(0.1) .topP(0.9) .numPredict(2048) + // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 + .think(false) + .returnThinking(false) .timeout(Duration.ofSeconds(180)) .build(); } diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 1fe942f..4ee4cac 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -13,6 +13,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -87,21 +89,31 @@ public class ErpClient { * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 * 会话过期(code=-2)时自动重登一次并重试。 */ - public JsonNode readForm(String formId, String moduleId, int page, int pageSize) { - JsonNode root = doRead(formId, moduleId, page, pageSize, token()); + public JsonNode readForm(String formId, String moduleId, int page, int pageSize, + String filterField, String filterValue) { + JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); if (root.path("code").asInt() == -2) { login(); - root = doRead(formId, moduleId, page, pageSize, token()); + root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); } return root; } - private JsonNode doRead(String formId, String moduleId, int page, int pageSize, String tok) { + private JsonNode doRead(String formId, String moduleId, int page, int pageSize, + String filterField, String filterValue, String tok) { try { String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId + "?sModelsId=" + moduleId + "&sName="; + List> bFilter = new ArrayList<>(); + if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) { + Map f = new LinkedHashMap<>(); + f.put("bFilterName", filterField); + f.put("bFilterCondition", "like"); + f.put("bFilterValue", filterValue.trim()); + bFilter.add(f); + } String body = mapper.writeValueAsString(Map.of( - "pageNum", page, "pageSize", pageSize, "bFilter", List.of())); + "pageNum", page, "pageSize", pageSize, "bFilter", bFilter)); HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "application/json;charset=UTF-8") .header("Authorization", tok) diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index c87bfeb..57093b9 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -24,6 +24,8 @@ public class SystemPromptService { public String buildSystemPrompt() { return """ + 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 + 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ 帮助他们查询和(未来)操作 ERP 里的业务单据。 @@ -33,9 +35,11 @@ public class SystemPromptService { %s 【可用工具】 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 - - readFormData(formId, moduleId):读取该表单的真实业务数据(返回前若干行 + 总条数)。 + - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ + keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ - (可小结总条数、列出前几条)。绝不自己编表单名或数据。 + (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ + (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 【行为准则】 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 @@ -43,7 +47,8 @@ public class SystemPromptService { 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 - 5. 信息不足时主动向用户提问,不要猜测。 + 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ + 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 """.formatted(renderDomainMap()); } diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index eb9cb74..62661a2 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -37,19 +37,23 @@ public class ErpReadTool { this.jdbc = jdbc; } - @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。" - + "必须先用 findForms 得到目标表单的 formId 与 moduleId,再调用本工具。") + @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" + + "(如查某个客户 / 物料)。必须先用 findForms 得到目标表单的 formId 与 moduleId。") public String readFormData( @P("表单id(findForms 返回的 formId)") String formId, - @P("菜单id(findForms 返回的 moduleId)") String moduleId) { + @P("菜单id(findForms 返回的 moduleId)") String moduleId, + @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);" + + "问数量 / 全部 / 概况时必须留空", required = false) String keyword) { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; } + String kw = (keyword == null) ? "" : keyword.trim(); + String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim()); JsonNode root; try { - root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS); + root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); } catch (Exception e) { return "读取失败:" + e.getMessage(); } @@ -70,8 +74,17 @@ public class ErpReadTool { Map labels = loadLabels(formId.trim()); List cols = pickColumns(data.get(0), labels); + String scope; + if (!kw.isEmpty() && nameField != null) { + scope = "(按名称含「" + kw + "」筛选)"; + } else if (!kw.isEmpty()) { + scope = "(未识别到该表单的名称字段,无法按「" + kw + "」过滤,返回前几条)"; + } else { + scope = ""; + } + StringBuilder sb = new StringBuilder(); - sb.append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); + sb.append(scope).append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); sb.append("| ") .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) .append(" |\n"); @@ -108,6 +121,23 @@ public class ErpReadTool { return m; } + /** 猜测该表单的"名称"字段(用于按关键词过滤):数据源表里以 Name 结尾、使用最广的字段。 */ + private String resolveNameField(String formId) { + try { + List> rows = jdbc.queryForList( + "SELECT fd.sField AS f FROM viw_ai_useful_forms af " + + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + + "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + + "ORDER BY fd.iFormUses DESC LIMIT 1", formId); + if (!rows.isEmpty() && rows.get(0).get("f") != null) { + return rows.get(0).get("f").toString(); + } + } catch (Exception ignore) { + // 无字典时不过滤 + } + return null; + } + /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ private List pickColumns(JsonNode first, Map labels) { List labeled = new ArrayList<>(); diff --git a/src/main/java/com/xly/tool/KgQueryTool.java b/src/main/java/com/xly/tool/KgQueryTool.java index c898893..16ab970 100644 --- a/src/main/java/com/xly/tool/KgQueryTool.java +++ b/src/main/java/com/xly/tool/KgQueryTool.java @@ -38,11 +38,13 @@ public class KgQueryTool { String like = "%" + kw + "%"; List> rows = jdbc.queryForList( - "SELECT sFormTitle, sDataSource, sExecType, sModuleId, sFormId " + - "FROM viw_ai_useful_forms " + - "WHERE sFormTitle LIKE ? " + - "ORDER BY CHAR_LENGTH(sFormTitle) ASC " + - "LIMIT 15", + "SELECT af.sFormTitle, af.sDataSource, af.sExecType, af.sModuleId, af.sFormId " + + "FROM viw_ai_useful_forms af " + + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + + "WHERE af.sFormTitle LIKE ? " + + "ORDER BY COALESCE(f.bAiTool,0) DESC, (af.sExecType='table') DESC, " + + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC " + + "LIMIT 12", like); if (rows.isEmpty()) { diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml index b74758b..a6da75f 100644 --- a/src/main/resources/application-saaslocal.yml +++ b/src/main/resources/application-saaslocal.yml @@ -25,6 +25,12 @@ ocr: # same local DB. The committed application.yml erp.baseurl targets the remote deploy. # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); # production should instead pass through the user's own browser ERP token per request. +langchain4j: + ollama: + # qwen3:14b follows instructions better and keeps its chain-of-thought on a + # separate channel (Ollama "thinking"), so it doesn't leak into the streamed content. + chat-model-name: qwen3:14b + erp: baseurl: http://127.0.0.1:8697/xlyEntry dev-login: -- libgit2 0.22.2