Commit 2cef96e4aa774b3504e2046e6b70cce0578f25ec
1 parent
42330ffc
feat(C): read robustness + model reliability
- ErpReadTool: optional keyword -> LIKE filter on the form's resolved name field (viw_kg_field_dict, *Name); keyword doc clarified (only for locating a named record, empty for counts) - ErpClient.readForm: accepts filterField/filterValue -> bFilter - KgQueryTool.findForms: rank by bAiTool(curated) + table + flow-connectivity + name length so the top hit is usually the canonical form - SystemPromptService: hard 'always Simplified Chinese, no other language' rule (kills qwen language drift); prefer top form; read directly instead of over-asking - AgentConfig: qwen3 think(false)/returnThinking(false) -> fast + disciplined tool-calling, no leaked chain-of-thought - application-saaslocal.yml: chat model -> qwen3:14b Verified: no more foreign-language leaks; '多少客户'->92; keyword lookup finds 必胜客; proc-backed stock forms readable; greeting 2s, form lookup ~6s. Known limit: which form/columns get picked for a specific-field-of-specific-record query still varies (deferred KgSearch).
Showing
6 changed files
with
76 additions
and
18 deletions
src/main/java/com/xly/config/AgentConfig.java
src/main/java/com/xly/service/ErpClient.java
| ... | ... | @@ -13,6 +13,8 @@ import java.net.http.HttpRequest; |
| 13 | 13 | import java.net.http.HttpResponse; |
| 14 | 14 | import java.nio.charset.StandardCharsets; |
| 15 | 15 | import java.time.Duration; |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.LinkedHashMap; | |
| 16 | 18 | import java.util.List; |
| 17 | 19 | import java.util.Map; |
| 18 | 20 | |
| ... | ... | @@ -87,21 +89,31 @@ public class ErpClient { |
| 87 | 89 | * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。 |
| 88 | 90 | * 会话过期(code=-2)时自动重登一次并重试。 |
| 89 | 91 | */ |
| 90 | - public JsonNode readForm(String formId, String moduleId, int page, int pageSize) { | |
| 91 | - JsonNode root = doRead(formId, moduleId, page, pageSize, token()); | |
| 92 | + public JsonNode readForm(String formId, String moduleId, int page, int pageSize, | |
| 93 | + String filterField, String filterValue) { | |
| 94 | + JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); | |
| 92 | 95 | if (root.path("code").asInt() == -2) { |
| 93 | 96 | login(); |
| 94 | - root = doRead(formId, moduleId, page, pageSize, token()); | |
| 97 | + root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); | |
| 95 | 98 | } |
| 96 | 99 | return root; |
| 97 | 100 | } |
| 98 | 101 | |
| 99 | - private JsonNode doRead(String formId, String moduleId, int page, int pageSize, String tok) { | |
| 102 | + private JsonNode doRead(String formId, String moduleId, int page, int pageSize, | |
| 103 | + String filterField, String filterValue, String tok) { | |
| 100 | 104 | try { |
| 101 | 105 | String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + formId |
| 102 | 106 | + "?sModelsId=" + moduleId + "&sName="; |
| 107 | + List<Map<String, Object>> bFilter = new ArrayList<>(); | |
| 108 | + if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) { | |
| 109 | + Map<String, Object> f = new LinkedHashMap<>(); | |
| 110 | + f.put("bFilterName", filterField); | |
| 111 | + f.put("bFilterCondition", "like"); | |
| 112 | + f.put("bFilterValue", filterValue.trim()); | |
| 113 | + bFilter.add(f); | |
| 114 | + } | |
| 103 | 115 | String body = mapper.writeValueAsString(Map.of( |
| 104 | - "pageNum", page, "pageSize", pageSize, "bFilter", List.of())); | |
| 116 | + "pageNum", page, "pageSize", pageSize, "bFilter", bFilter)); | |
| 105 | 117 | HttpRequest req = HttpRequest.newBuilder(URI.create(url)) |
| 106 | 118 | .header("Content-Type", "application/json;charset=UTF-8") |
| 107 | 119 | .header("Authorization", tok) | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -24,6 +24,8 @@ public class SystemPromptService { |
| 24 | 24 | |
| 25 | 25 | public String buildSystemPrompt() { |
| 26 | 26 | return """ |
| 27 | + 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 | |
| 28 | + | |
| 27 | 29 | 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ |
| 28 | 30 | 帮助他们查询和(未来)操作 ERP 里的业务单据。 |
| 29 | 31 | |
| ... | ... | @@ -33,9 +35,11 @@ public class SystemPromptService { |
| 33 | 35 | %s |
| 34 | 36 | 【可用工具】 |
| 35 | 37 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 36 | - - readFormData(formId, moduleId):读取该表单的真实业务数据(返回前若干行 + 总条数)。 | |
| 38 | + - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ | |
| 39 | + keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 | |
| 37 | 40 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
| 38 | - (可小结总条数、列出前几条)。绝不自己编表单名或数据。 | |
| 41 | + (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ | |
| 42 | + (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 | |
| 39 | 43 | |
| 40 | 44 | 【行为准则】 |
| 41 | 45 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 |
| ... | ... | @@ -43,7 +47,8 @@ public class SystemPromptService { |
| 43 | 47 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 44 | 48 | 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ |
| 45 | 49 | 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 |
| 46 | - 5. 信息不足时主动向用户提问,不要猜测。 | |
| 50 | + 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ | |
| 51 | + 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 | |
| 47 | 52 | """.formatted(renderDomainMap()); |
| 48 | 53 | } |
| 49 | 54 | ... | ... |
src/main/java/com/xly/tool/ErpReadTool.java
| ... | ... | @@ -37,19 +37,23 @@ public class ErpReadTool { |
| 37 | 37 | this.jdbc = jdbc; |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | - @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。" | |
| 41 | - + "必须先用 findForms 得到目标表单的 formId 与 moduleId,再调用本工具。") | |
| 40 | + @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" | |
| 41 | + + "(如查某个客户 / 物料)。必须先用 findForms 得到目标表单的 formId 与 moduleId。") | |
| 42 | 42 | public String readFormData( |
| 43 | 43 | @P("表单id(findForms 返回的 formId)") String formId, |
| 44 | - @P("菜单id(findForms 返回的 moduleId)") String moduleId) { | |
| 44 | + @P("菜单id(findForms 返回的 moduleId)") String moduleId, | |
| 45 | + @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);" | |
| 46 | + + "问数量 / 全部 / 概况时必须留空", required = false) String keyword) { | |
| 45 | 47 | |
| 46 | 48 | if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { |
| 47 | 49 | return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; |
| 48 | 50 | } |
| 51 | + String kw = (keyword == null) ? "" : keyword.trim(); | |
| 52 | + String nameField = kw.isEmpty() ? null : resolveNameField(formId.trim()); | |
| 49 | 53 | |
| 50 | 54 | JsonNode root; |
| 51 | 55 | try { |
| 52 | - root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS); | |
| 56 | + root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); | |
| 53 | 57 | } catch (Exception e) { |
| 54 | 58 | return "读取失败:" + e.getMessage(); |
| 55 | 59 | } |
| ... | ... | @@ -70,8 +74,17 @@ public class ErpReadTool { |
| 70 | 74 | Map<String, String> labels = loadLabels(formId.trim()); |
| 71 | 75 | List<String> cols = pickColumns(data.get(0), labels); |
| 72 | 76 | |
| 77 | + String scope; | |
| 78 | + if (!kw.isEmpty() && nameField != null) { | |
| 79 | + scope = "(按名称含「" + kw + "」筛选)"; | |
| 80 | + } else if (!kw.isEmpty()) { | |
| 81 | + scope = "(未识别到该表单的名称字段,无法按「" + kw + "」过滤,返回前几条)"; | |
| 82 | + } else { | |
| 83 | + scope = ""; | |
| 84 | + } | |
| 85 | + | |
| 73 | 86 | StringBuilder sb = new StringBuilder(); |
| 74 | - sb.append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); | |
| 87 | + sb.append(scope).append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); | |
| 75 | 88 | sb.append("| ") |
| 76 | 89 | .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) |
| 77 | 90 | .append(" |\n"); |
| ... | ... | @@ -108,6 +121,23 @@ public class ErpReadTool { |
| 108 | 121 | return m; |
| 109 | 122 | } |
| 110 | 123 | |
| 124 | + /** 猜测该表单的"名称"字段(用于按关键词过滤):数据源表里以 Name 结尾、使用最广的字段。 */ | |
| 125 | + private String resolveNameField(String formId) { | |
| 126 | + try { | |
| 127 | + List<Map<String, Object>> rows = jdbc.queryForList( | |
| 128 | + "SELECT fd.sField AS f FROM viw_ai_useful_forms af " + | |
| 129 | + "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " + | |
| 130 | + "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " + | |
| 131 | + "ORDER BY fd.iFormUses DESC LIMIT 1", formId); | |
| 132 | + if (!rows.isEmpty() && rows.get(0).get("f") != null) { | |
| 133 | + return rows.get(0).get("f").toString(); | |
| 134 | + } | |
| 135 | + } catch (Exception ignore) { | |
| 136 | + // 无字典时不过滤 | |
| 137 | + } | |
| 138 | + return null; | |
| 139 | + } | |
| 140 | + | |
| 111 | 141 | /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ |
| 112 | 142 | private List<String> pickColumns(JsonNode first, Map<String, String> labels) { |
| 113 | 143 | List<String> labeled = new ArrayList<>(); | ... | ... |
src/main/java/com/xly/tool/KgQueryTool.java
| ... | ... | @@ -38,11 +38,13 @@ public class KgQueryTool { |
| 38 | 38 | String like = "%" + kw + "%"; |
| 39 | 39 | |
| 40 | 40 | List<Map<String, Object>> rows = jdbc.queryForList( |
| 41 | - "SELECT sFormTitle, sDataSource, sExecType, sModuleId, sFormId " + | |
| 42 | - "FROM viw_ai_useful_forms " + | |
| 43 | - "WHERE sFormTitle LIKE ? " + | |
| 44 | - "ORDER BY CHAR_LENGTH(sFormTitle) ASC " + | |
| 45 | - "LIMIT 15", | |
| 41 | + "SELECT af.sFormTitle, af.sDataSource, af.sExecType, af.sModuleId, af.sFormId " + | |
| 42 | + "FROM viw_ai_useful_forms af " + | |
| 43 | + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + | |
| 44 | + "WHERE af.sFormTitle LIKE ? " + | |
| 45 | + "ORDER BY COALESCE(f.bAiTool,0) DESC, (af.sExecType='table') DESC, " + | |
| 46 | + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC " + | |
| 47 | + "LIMIT 12", | |
| 46 | 48 | like); |
| 47 | 49 | |
| 48 | 50 | if (rows.isEmpty()) { | ... | ... |
src/main/resources/application-saaslocal.yml
| ... | ... | @@ -25,6 +25,12 @@ ocr: |
| 25 | 25 | # same local DB. The committed application.yml erp.baseurl targets the remote deploy. |
| 26 | 26 | # dev-login lets xlyAi mint a working ERP session locally (admin/666666, brand 1111111111); |
| 27 | 27 | # production should instead pass through the user's own browser ERP token per request. |
| 28 | +langchain4j: | |
| 29 | + ollama: | |
| 30 | + # qwen3:14b follows instructions better and keeps its chain-of-thought on a | |
| 31 | + # separate channel (Ollama "thinking"), so it doesn't leak into the streamed content. | |
| 32 | + chat-model-name: qwen3:14b | |
| 33 | + | |
| 28 | 34 | erp: |
| 29 | 35 | baseurl: http://127.0.0.1:8697/xlyEntry |
| 30 | 36 | dev-login: | ... | ... |