Commit 2cef96e4aa774b3504e2046e6b70cce0578f25ec

Authored by zichun
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).
src/main/java/com/xly/config/AgentConfig.java
... ... @@ -37,6 +37,9 @@ public class AgentConfig {
37 37 .temperature(0.1)
38 38 .topP(0.9)
39 39 .numPredict(2048)
  40 + // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答
  41 + .think(false)
  42 + .returnThinking(false)
40 43 .timeout(Duration.ofSeconds(180))
41 44 .build();
42 45 }
... ...
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:
... ...