Commit 9dba4b16c0cdfbf6e26f4b1ddcaddae8e0515b2f
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
Showing
6 changed files
with
158 additions
and
28 deletions
src/main/java/com/xly/service/FormResolverService.java
| ... | ... | @@ -180,11 +180,19 @@ public class FormResolverService { |
| 180 | 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 | 195 | try { |
| 187 | - // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表 | |
| 188 | 196 | Integer ok = jdbc.queryForObject( |
| 189 | 197 | "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); |
| 190 | 198 | if (ok == null || ok == 0) { |
| ... | ... | @@ -194,11 +202,26 @@ public class FormResolverService { |
| 194 | 202 | if (nameField == null) { |
| 195 | 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 | 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 | 225 | List<Object> args = new ArrayList<>(); |
| 203 | 226 | if (brand != null && !brand.isBlank()) { |
| 204 | 227 | args.add(brand); |
| ... | ... | @@ -206,17 +229,34 @@ public class FormResolverService { |
| 206 | 229 | if (q != null && !q.isBlank()) { |
| 207 | 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 | 248 | } catch (Exception ignore) { |
| 216 | 249 | } |
| 217 | 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 | 260 | /** 表列 -> MySQL 数据类型(information_schema)。 */ |
| 221 | 261 | public Map<String, String> columnTypes(String table) { |
| 222 | 262 | Map<String, String> m = new HashMap<>(); | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -50,7 +50,7 @@ public class SystemPromptService { |
| 50 | 50 | |
| 51 | 51 | 【可用工具(只有这些)】 |
| 52 | 52 | - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 53 | - - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 | |
| 53 | + - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。 | |
| 54 | 54 | - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 |
| 55 | 55 | - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 |
| 56 | 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 | 111 | } |
| 112 | 112 | |
| 113 | 113 | @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" |
| 114 | - + "(如查某个客户 / 物料)。必须先用 findForms 得到目标表单的 formId 与 moduleId。") | |
| 114 | + + "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。" | |
| 115 | + + "必须先用 findForms 得到目标表单的 formId 与 moduleId。") | |
| 115 | 116 | public String readFormData( |
| 116 | 117 | @P("表单id(findForms 返回的 formId)") String formId, |
| 117 | 118 | @P("菜单id(findForms 返回的 moduleId)") String moduleId, |
| 118 | 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 | 123 | if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { |
| 122 | 124 | return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; |
| ... | ... | @@ -126,10 +128,11 @@ public class ErpReadTool { |
| 126 | 128 | } |
| 127 | 129 | String kw = (keyword == null) ? "" : keyword.trim(); |
| 128 | 130 | String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); |
| 131 | + int pageNo = page == null || page < 1 ? 1 : page; | |
| 129 | 132 | |
| 130 | 133 | JsonNode root; |
| 131 | 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 | 136 | } catch (Exception e) { |
| 134 | 137 | return "读取失败:" + e.getMessage(); |
| 135 | 138 | } |
| ... | ... | @@ -160,7 +163,8 @@ public class ErpReadTool { |
| 160 | 163 | } |
| 161 | 164 | |
| 162 | 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 | 168 | sb.append("| ") |
| 165 | 169 | .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) |
| 166 | 170 | .append(" |\n"); | ... | ... |
src/main/java/com/xly/tool/InteractionTool.java
| ... | ... | @@ -45,10 +45,12 @@ public class InteractionTool { |
| 45 | 45 | out.put("type", "question"); |
| 46 | 46 | out.put("question", question == null ? "" : question.trim()); |
| 47 | 47 | out.put("options", opts); |
| 48 | + // Claude Code 风格:给了选项也永远保留「自由输入」作为最后一个选项 | |
| 49 | + out.put("allowFree", true); | |
| 48 | 50 | try { |
| 49 | 51 | return mapper.writeValueAsString(out); |
| 50 | 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 | 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 | 214 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { |
| 136 | 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 | 6 | import org.springframework.web.bind.annotation.RequestParam; |
| 7 | 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 | 19 | * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 |
| 17 | 20 | */ |
| 18 | 21 | @RestController |
| ... | ... | @@ -26,9 +29,11 @@ public class FormController { |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | 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 | } | ... | ... |