Commit 9dba4b16c0cdfbf6e26f4b1ddcaddae8e0515b2f

Authored by zichun
1 parent a9bf63b0

feat(protocol): deterministic form submit, paged multi-column FK options, askUse…

…r free-input flag, readFormData paging

- POST /api/agent/form/submit: structured fields go straight to
  proposeWrite(action=create), no LLM re-encoding; ledger/state/memory recorded
- GET /api/agent/form/options: {columns,rows,total,page,pageSize} with name search
- askUser result carries allowFree=true (last option is always free text)
- readFormData(page?) with page shown in output
src/main/java/com/xly/service/FormResolverService.java
@@ -180,11 +180,19 @@ public class FormResolverService { @@ -180,11 +180,19 @@ public class FormResolverService {
180 return m; 180 return m;
181 } 181 }
182 182
183 - /** FK 名称候选(下拉从对应表取选项):按名称字段模糊匹配,返回名称列表。仅允许字段字典里出现过的外键表。 */  
184 - public List<String> fkOptions(String fkTable, String brand, String q) {  
185 - List<String> out = new ArrayList<>(); 183 + /**
  184 + * FK 选项页(二级选择器数据源):名称 + 若干展示列(字段字典按使用度选前 3 个非名称文本列),
  185 + * 支持按名称模糊搜索 + 分页。仅允许「在字段字典里作为外键目标出现过」的表,避免任意读表。
  186 + * 返回 {columns:[{col,label}…], rows:[{col:value…}], total, page, pageSize}。
  187 + */
  188 + public Map<String, Object> fkOptionPage(String fkTable, String brand, String q, int page, int pageSize) {
  189 + Map<String, Object> out = new LinkedHashMap<>();
  190 + out.put("columns", List.of());
  191 + out.put("rows", List.of());
  192 + out.put("total", 0);
  193 + out.put("page", Math.max(1, page));
  194 + out.put("pageSize", pageSize);
186 try { 195 try {
187 - // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表  
188 Integer ok = jdbc.queryForObject( 196 Integer ok = jdbc.queryForObject(
189 "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); 197 "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable);
190 if (ok == null || ok == 0) { 198 if (ok == null || ok == 0) {
@@ -194,11 +202,26 @@ public class FormResolverService { @@ -194,11 +202,26 @@ public class FormResolverService {
194 if (nameField == null) { 202 if (nameField == null) {
195 return out; 203 return out;
196 } 204 }
197 - String sql = "SELECT DISTINCT `" + nameField + "` n FROM `" + fkTable + "` " +  
198 - "WHERE IFNULL(`" + nameField + "`,'')<>'' " + 205 + // 展示列:名称列 + 使用度最高的最多 3 个其它文本列(排除系统/Id 列)
  206 + List<Map<String, Object>> cols = new ArrayList<>();
  207 + cols.add(Map.of("col", nameField, "label", labelOf(fkTable, nameField)));
  208 + for (Map<String, Object> r : jdbc.queryForList(
  209 + "SELECT sField, SUBSTRING_INDEX(GROUP_CONCAT(sChinese ORDER BY CHAR_LENGTH(sChinese) ASC SEPARATOR 0x1f),0x1f,1) zh " +
  210 + "FROM viw_kg_field_dict WHERE sTable=? AND sField<>? AND sField NOT LIKE '%Id' " +
  211 + "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 8", fkTable, nameField)) {
  212 + String c = String.valueOf(r.get("sField"));
  213 + if (isSystemColumn(c) || cols.size() >= 4) {
  214 + continue;
  215 + }
  216 + cols.add(Map.of("col", c, "label", String.valueOf(r.get("zh"))));
  217 + }
  218 + StringBuilder sel = new StringBuilder("sId");
  219 + for (Map<String, Object> c : cols) {
  220 + sel.append(", `").append(c.get("col")).append("`");
  221 + }
  222 + String where = "WHERE IFNULL(`" + nameField + "`,'')<>'' " +
199 (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") + 223 (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") +
200 - (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "") +  
201 - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 50"; 224 + (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "");
202 List<Object> args = new ArrayList<>(); 225 List<Object> args = new ArrayList<>();
203 if (brand != null && !brand.isBlank()) { 226 if (brand != null && !brand.isBlank()) {
204 args.add(brand); 227 args.add(brand);
@@ -206,17 +229,34 @@ public class FormResolverService { @@ -206,17 +229,34 @@ public class FormResolverService {
206 if (q != null && !q.isBlank()) { 229 if (q != null && !q.isBlank()) {
207 args.add("%" + q.trim() + "%"); 230 args.add("%" + q.trim() + "%");
208 } 231 }
209 - for (Map<String, Object> r : jdbc.queryForList(sql, args.toArray())) {  
210 - Object n = r.get("n");  
211 - if (n != null) {  
212 - out.add(n.toString());  
213 - }  
214 - } 232 + Integer total = jdbc.queryForObject(
  233 + "SELECT COUNT(*) FROM `" + fkTable + "` " + where, Integer.class, args.toArray());
  234 + int p = Math.max(1, page);
  235 + int ps = Math.min(Math.max(1, pageSize), 100);
  236 + List<Object> pageArgs = new ArrayList<>(args);
  237 + pageArgs.add(ps);
  238 + pageArgs.add((p - 1) * ps);
  239 + List<Map<String, Object>> rows = jdbc.queryForList(
  240 + "SELECT " + sel + " FROM `" + fkTable + "` " + where +
  241 + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC, `" + nameField + "` ASC LIMIT ? OFFSET ?",
  242 + pageArgs.toArray());
  243 + out.put("columns", cols);
  244 + out.put("rows", rows);
  245 + out.put("total", total == null ? 0 : total);
  246 + out.put("page", p);
  247 + out.put("pageSize", ps);
215 } catch (Exception ignore) { 248 } catch (Exception ignore) {
216 } 249 }
217 return out; 250 return out;
218 } 251 }
219 252
  253 + private String labelOf(String table, String field) {
  254 + String zh = queryOne(
  255 + "SELECT sChinese FROM viw_kg_field_dict WHERE sTable=? AND sField=? ORDER BY CHAR_LENGTH(sChinese) ASC LIMIT 1",
  256 + table, field);
  257 + return zh == null ? field : zh;
  258 + }
  259 +
220 /** 表列 -> MySQL 数据类型(information_schema)。 */ 260 /** 表列 -> MySQL 数据类型(information_schema)。 */
221 public Map<String, String> columnTypes(String table) { 261 public Map<String, String> columnTypes(String table) {
222 Map<String, String> m = new HashMap<>(); 262 Map<String, String> m = new HashMap<>();
src/main/java/com/xly/service/SystemPromptService.java
@@ -50,7 +50,7 @@ public class SystemPromptService { @@ -50,7 +50,7 @@ public class SystemPromptService {
50 50
51 【可用工具(只有这些)】 51 【可用工具(只有这些)】
52 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 52 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
53 - - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword 53 + - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增
54 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 54 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。
55 - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 55 - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。
56 - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 56 - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
src/main/java/com/xly/tool/ErpReadTool.java
@@ -111,12 +111,14 @@ public class ErpReadTool { @@ -111,12 +111,14 @@ public class ErpReadTool {
111 } 111 }
112 112
113 @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" 113 @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤"
114 - + "(如查某个客户 / 物料)。必须先用 findForms 得到目标表单的 formId 与 moduleId。") 114 + + "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。"
  115 + + "必须先用 findForms 得到目标表单的 formId 与 moduleId。")
115 public String readFormData( 116 public String readFormData(
116 @P("表单id(findForms 返回的 formId)") String formId, 117 @P("表单id(findForms 返回的 formId)") String formId,
117 @P("菜单id(findForms 返回的 moduleId)") String moduleId, 118 @P("菜单id(findForms 返回的 moduleId)") String moduleId,
118 @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);" 119 @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);"
119 - + "问数量 / 全部 / 概况时必须留空", required = false) String keyword) { 120 + + "问数量 / 全部 / 概况时必须留空", required = false) String keyword,
  121 + @P(value = "可选:页码,从 1 开始;默认 1", required = false) Integer page) {
120 122
121 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { 123 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) {
122 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; 124 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。";
@@ -126,10 +128,11 @@ public class ErpReadTool { @@ -126,10 +128,11 @@ public class ErpReadTool {
126 } 128 }
127 String kw = (keyword == null) ? "" : keyword.trim(); 129 String kw = (keyword == null) ? "" : keyword.trim();
128 String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); 130 String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim());
  131 + int pageNo = page == null || page < 1 ? 1 : page;
129 132
130 JsonNode root; 133 JsonNode root;
131 try { 134 try {
132 - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); 135 + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw);
133 } catch (Exception e) { 136 } catch (Exception e) {
134 return "读取失败:" + e.getMessage(); 137 return "读取失败:" + e.getMessage();
135 } 138 }
@@ -160,7 +163,8 @@ public class ErpReadTool { @@ -160,7 +163,8 @@ public class ErpReadTool {
160 } 163 }
161 164
162 StringBuilder sb = new StringBuilder(); 165 StringBuilder sb = new StringBuilder();
163 - sb.append(scope).append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); 166 + sb.append(scope).append("共 ").append(total).append(" 条,第 ").append(pageNo)
  167 + .append(" 页 ").append(data.size()).append(" 条:\n\n");
164 sb.append("| ") 168 sb.append("| ")
165 .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) 169 .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | ")))
166 .append(" |\n"); 170 .append(" |\n");
src/main/java/com/xly/tool/InteractionTool.java
@@ -45,10 +45,12 @@ public class InteractionTool { @@ -45,10 +45,12 @@ public class InteractionTool {
45 out.put("type", "question"); 45 out.put("type", "question");
46 out.put("question", question == null ? "" : question.trim()); 46 out.put("question", question == null ? "" : question.trim());
47 out.put("options", opts); 47 out.put("options", opts);
  48 + // Claude Code 风格:给了选项也永远保留「自由输入」作为最后一个选项
  49 + out.put("allowFree", true);
48 try { 50 try {
49 return mapper.writeValueAsString(out); 51 return mapper.writeValueAsString(out);
50 } catch (Exception e) { 52 } catch (Exception e) {
51 - return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[]}"; 53 + return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[],\"allowFree\":true}";
52 } 54 }
53 } 55 }
54 } 56 }
src/main/java/com/xly/web/AgentChatController.java
@@ -131,6 +131,85 @@ public class AgentChatController { @@ -131,6 +131,85 @@ public class AgentChatController {
131 return emitter; 131 return emitter;
132 } 132 }
133 133
  134 + public static class FormSubmitReq {
  135 + public String entity;
  136 + public Map<String, String> fields; // 字段中文名 -> 值
  137 + public String userid;
  138 + public String conversationId;
  139 + public String authorization;
  140 + public String brandsid;
  141 + public String subsidiaryid;
  142 + public String usertype;
  143 + }
  144 +
  145 + /**
  146 + * 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create),
  147 + * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
  148 + */
  149 + @PostMapping("/form/submit")
  150 + public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req) {
  151 + final String convId = (req.conversationId != null && !req.conversationId.isBlank())
  152 + ? req.conversationId
  153 + : ((req.userid == null ? "anon" : req.userid) + ":default");
  154 + Map<String, Object> out = new LinkedHashMap<>();
  155 + String entity = req.entity == null ? "" : req.entity.trim();
  156 + Map<String, String> fields = req.fields == null ? Map.of() : req.fields;
  157 + if (entity.isEmpty() || fields.isEmpty()) {
  158 + out.put("error", "缺少单据类型或表单字段。");
  159 + return out;
  160 + }
  161 + ChatReq idReq = new ChatReq();
  162 + idReq.userid = req.userid;
  163 + idReq.authorization = req.authorization;
  164 + idReq.brandsid = req.brandsid;
  165 + idReq.subsidiaryid = req.subsidiaryid;
  166 + idReq.usertype = req.usertype;
  167 + AgentIdentity identity = resolveIdentity(idReq);
  168 +
  169 + StringBuilder parts = new StringBuilder();
  170 + fields.forEach((k, v) -> {
  171 + if (v != null && !v.isBlank()) {
  172 + if (parts.length() > 0) parts.append(",");
  173 + parts.append(k).append("=").append(v);
  174 + }
  175 + });
  176 + String userText = "提交「" + entity + "」新增表单:" + parts;
  177 + conversations.touch(req.userid == null ? "anon" : req.userid, convId, userText);
  178 + ledger.append(convId, "user", Map.of("text", userText));
  179 +
  180 + String fieldsJson;
  181 + try {
  182 + fieldsJson = mapper.writeValueAsString(fields);
  183 + } catch (Exception e) {
  184 + out.put("error", "字段序列化失败:" + e.getMessage());
  185 + return out;
  186 + }
  187 + String result = agentFactory.proposeWriteTool(identity)
  188 + .proposeWrite("create", entity, null, null, null, fieldsJson);
  189 + try {
  190 + JsonNode r = mapper.readTree(result);
  191 + String opId = r.path("opId").asText(null);
  192 + if (opId != null && !opId.isBlank()) {
  193 + ops.attachConversation(opId, convId);
  194 + String summary = r.path("summary").asText("");
  195 + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
  196 + state.setActiveDoc(convId, entity, "", opId, "proposed");
  197 + appendMemoryTurn(convId, userText, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");
  198 + out.put("opId", opId);
  199 + out.put("summary", summary);
  200 + out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
  201 + } else {
  202 + String err = r.path("error").asText("无法完成该新增。");
  203 + ledger.append(convId, "assistant", Map.of("text", err));
  204 + appendMemoryTurn(convId, userText, err);
  205 + out.put("error", err);
  206 + }
  207 + } catch (Exception e) {
  208 + out.put("error", "服务异常:" + e.getMessage());
  209 + }
  210 + return out;
  211 + }
  212 +
134 /** 意图门 + 确定性路由。 */ 213 /** 意图门 + 确定性路由。 */
135 private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { 214 private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {
136 // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 215 // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。
src/main/java/com/xly/web/FormController.java
@@ -6,13 +6,16 @@ import org.springframework.web.bind.annotation.RequestMapping; @@ -6,13 +6,16 @@ import org.springframework.web.bind.annotation.RequestMapping;
6 import org.springframework.web.bind.annotation.RequestParam; 6 import org.springframework.web.bind.annotation.RequestParam;
7 import org.springframework.web.bind.annotation.RestController; 7 import org.springframework.web.bind.annotation.RestController;
8 8
9 -import java.util.List; 9 +import java.util.Map;
10 10
11 /** 11 /**
12 - * FormCollect 的**下拉选项来源**端点:外键字段(客户/产品/物料…)的选项从对应表实时取。 12 + * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取,
  13 + * 多列展示 + 名称搜索 + 分页。
13 * 14 *
14 - * <p>前端渲染 {@code type=fkselect} 字段时调 {@code GET /api/agent/form/options?table=elecustomer&q=..}  
15 - * 拿候选名称填进下拉。安全:{@link FormResolverService#fkOptions} 只允许「在字段字典里作为外键目标出现过」的表, 15 + * <p>前端渲染 {@code type=fkselect} 字段时调
  16 + * {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20},
  17 + * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。
  18 + * 安全:{@link FormResolverService#fkOptionPage} 只允许「在字段字典里作为外键目标出现过」的表,
16 * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 19 * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。
17 */ 20 */
18 @RestController 21 @RestController
@@ -26,9 +29,11 @@ public class FormController { @@ -26,9 +29,11 @@ public class FormController {
26 } 29 }
27 30
28 @GetMapping("/options") 31 @GetMapping("/options")
29 - public List<String> options(@RequestParam("table") String table,  
30 - @RequestParam(value = "q", required = false) String q,  
31 - @RequestParam(value = "brandsid", required = false) String brandsid) {  
32 - return resolver.fkOptions(table, brandsid, q); 32 + public Map<String, Object> options(@RequestParam("table") String table,
  33 + @RequestParam(value = "q", required = false) String q,
  34 + @RequestParam(value = "brandsid", required = false) String brandsid,
  35 + @RequestParam(value = "page", defaultValue = "1") int page,
  36 + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
  37 + return resolver.fkOptionPage(table, brandsid, q, page, pageSize);
33 } 38 }
34 } 39 }