Commit edc349ba63d26de0342c6c64982a15c391ac1a27
1 parent
58b65d9d
feat: 选择器点中的记录 id 直接采信(boundIds),同名不再有歧义
用户在 FK 选择器里点中某一行后,前端把该行 id 随保存回传(form/submit 与 preview/save 均加 boundIds:字段技术名→id),normalize 有 id 时直接绑定、 不按名称反查——候选的权限/租户/级联判定以 ERP 的下拉配置为准。 未经选择、仅凭名称(AI 预填或手打)的路径不变,仍保持同名 fail-closed。 幻影列硬闸在 id 直绑路径上同样生效。 回归锁 +2(FormRenderNormalizeTest 8 例),46 单测绿。 实测:同名产品不给 id → 报同名拒绝;给 id → 精确绑定、ERP 执行 success+200, 落库 BJD202607092 的客户/产品 id 与点中行一致。
Showing
9 changed files
with
642 additions
and
16 deletions
docs/agent-architecture-report.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8"> | |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | +<title>xlyAi Agent 架构报告</title> | |
| 7 | +<style> | |
| 8 | + :root { | |
| 9 | + --ink: #1a2332; | |
| 10 | + --ink-2: #4a5568; | |
| 11 | + --ink-3: #8494a8; | |
| 12 | + --line: #dde4ec; | |
| 13 | + --bg: #f7f9fb; | |
| 14 | + --card: #ffffff; | |
| 15 | + --blue: #2563eb; | |
| 16 | + --blue-bg: #eff4ff; | |
| 17 | + --green: #0e9f6e; | |
| 18 | + --green-bg: #eafaf3; | |
| 19 | + --amber: #b45309; | |
| 20 | + --amber-bg: #fdf3e3; | |
| 21 | + --red: #dc2626; | |
| 22 | + --red-bg: #fdeeee; | |
| 23 | + --purple: #7c3aed; | |
| 24 | + --purple-bg: #f3efff; | |
| 25 | + --teal: #0d9488; | |
| 26 | + --teal-bg: #e8f7f5; | |
| 27 | + } | |
| 28 | + * { box-sizing: border-box; } | |
| 29 | + body { | |
| 30 | + margin: 0; background: var(--bg); color: var(--ink); | |
| 31 | + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; | |
| 32 | + line-height: 1.75; font-size: 15px; | |
| 33 | + } | |
| 34 | + header.hero { | |
| 35 | + background: linear-gradient(135deg, #16213a 0%, #1e3a5f 60%, #24507e 100%); | |
| 36 | + color: #fff; padding: 44px 24px 36px; | |
| 37 | + } | |
| 38 | + .wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; } | |
| 39 | + header.hero h1 { margin: 0 0 8px; font-size: 30px; letter-spacing: 1px; } | |
| 40 | + header.hero p.sub { margin: 4px 0; color: #b9c8dd; font-size: 14px; } | |
| 41 | + header.hero .meta { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 10px; } | |
| 42 | + header.hero .meta span { | |
| 43 | + background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.2); | |
| 44 | + border-radius: 999px; padding: 3px 14px; font-size: 12.5px; color: #dce7f5; | |
| 45 | + } | |
| 46 | + nav.toc { | |
| 47 | + background: var(--card); border-bottom: 1px solid var(--line); | |
| 48 | + position: sticky; top: 0; z-index: 50; | |
| 49 | + } | |
| 50 | + nav.toc .wrap { display: flex; gap: 4px; overflow-x: auto; } | |
| 51 | + nav.toc a { | |
| 52 | + padding: 12px 16px; color: var(--ink-2); text-decoration: none; | |
| 53 | + font-size: 14px; white-space: nowrap; border-bottom: 2.5px solid transparent; | |
| 54 | + } | |
| 55 | + nav.toc a:hover { color: var(--blue); border-bottom-color: var(--blue); } | |
| 56 | + main { padding: 32px 0 70px; } | |
| 57 | + section.part { margin-bottom: 52px; } | |
| 58 | + .part-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 6px; } | |
| 59 | + .part-head .no { | |
| 60 | + font-size: 13px; font-weight: 700; color: #fff; background: var(--blue); | |
| 61 | + border-radius: 8px; padding: 3px 12px; letter-spacing: 1px; | |
| 62 | + } | |
| 63 | + .part-head h2 { margin: 0; font-size: 24px; } | |
| 64 | + .part > .wrap > p.lead { color: var(--ink-2); margin-top: 4px; max-width: 860px; } | |
| 65 | + h3 { font-size: 18px; margin: 32px 0 10px; padding-left: 10px; border-left: 4px solid var(--blue); } | |
| 66 | + h4 { font-size: 15.5px; margin: 0 0 6px; color: var(--ink); } | |
| 67 | + p { margin: 8px 0; } | |
| 68 | + .card { | |
| 69 | + background: var(--card); border: 1px solid var(--line); border-radius: 12px; | |
| 70 | + padding: 16px 20px; margin: 12px 0; | |
| 71 | + } | |
| 72 | + .card p { font-size: 14px; color: var(--ink-2); } | |
| 73 | + .grid { display: grid; gap: 12px; margin: 12px 0; } | |
| 74 | + .grid.c2 { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); } | |
| 75 | + .grid.c3 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } | |
| 76 | + table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 10px; overflow: hidden; margin: 12px 0; font-size: 13.8px; } | |
| 77 | + th { background: #eef2f7; text-align: left; padding: 9px 12px; font-weight: 600; color: var(--ink); border-bottom: 1.5px solid var(--line); white-space: nowrap; } | |
| 78 | + td { padding: 9px 12px; border-bottom: 1px solid #edf1f6; vertical-align: top; color: var(--ink-2); } | |
| 79 | + tr:last-child td { border-bottom: none; } | |
| 80 | + td b, li b, p b { color: var(--ink); } | |
| 81 | + code, .k { | |
| 82 | + font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; | |
| 83 | + font-size: 12.5px; background: #edf1f7; color: #334561; | |
| 84 | + border-radius: 5px; padding: 1px 6px; | |
| 85 | + } | |
| 86 | + .pill { display: inline-block; border-radius: 999px; padding: 1px 10px; font-size: 12px; font-weight: 600; } | |
| 87 | + .pill.ro { background: var(--green-bg); color: var(--green); } | |
| 88 | + .pill.hitl { background: var(--purple-bg); color: var(--purple); } | |
| 89 | + .pill.warn { background: var(--amber-bg); color: var(--amber); } | |
| 90 | + .pill.risk { background: var(--red-bg); color: var(--red); } | |
| 91 | + .pill.info { background: var(--blue-bg); color: var(--blue); } | |
| 92 | + ul { margin: 6px 0; padding-left: 22px; } | |
| 93 | + li { margin: 4px 0; color: var(--ink-2); font-size: 14px; } | |
| 94 | + .note { | |
| 95 | + border-left: 4px solid var(--amber); background: var(--amber-bg); | |
| 96 | + border-radius: 0 10px 10px 0; padding: 10px 16px; margin: 12px 0; font-size: 14px; color: #6b4a10; | |
| 97 | + } | |
| 98 | + .note.green { border-left-color: var(--green); background: var(--green-bg); color: #0c6b4d; } | |
| 99 | + | |
| 100 | + /* 盒图 */ | |
| 101 | + .diagram { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 20px; margin: 14px 0; overflow-x: auto; } | |
| 102 | + .diagram .title { font-size: 13px; color: var(--ink-3); text-align: center; margin-bottom: 12px; letter-spacing: 2px; } | |
| 103 | + .diagram .caption { font-size: 13.5px; color: var(--ink-3); margin: 8px 4px 0; } | |
| 104 | + .lane-row { display: flex; align-items: stretch; justify-content: center; gap: 0; min-width: 760px; } | |
| 105 | + .lane { | |
| 106 | + flex: 1; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px; | |
| 107 | + background: #fbfcfe; margin: 0 8px; min-width: 200px; | |
| 108 | + } | |
| 109 | + .lane .lane-title { font-weight: 700; text-align: center; font-size: 14px; margin-bottom: 8px; color: var(--ink); } | |
| 110 | + .lane .lane-sub { text-align: center; font-size: 11.5px; color: var(--ink-3); margin: -6px 0 8px; } | |
| 111 | + .box { | |
| 112 | + border-radius: 8px; padding: 7px 10px; margin: 7px 0; font-size: 12.8px; | |
| 113 | + border: 1px solid var(--line); background: #fff; color: var(--ink-2); text-align: center; | |
| 114 | + } | |
| 115 | + .box.b { background: var(--blue-bg); border-color: #c4d5f5; color: #1e40af; } | |
| 116 | + .box.g { background: var(--green-bg); border-color: #b6e6d3; color: #066a4a; } | |
| 117 | + .box.p { background: var(--purple-bg); border-color: #ddd0fa; color: #5b21b6; } | |
| 118 | + .box.t { background: var(--teal-bg); border-color: #b5e2dd; color: #0b6b62; } | |
| 119 | + .box.a { background: var(--amber-bg); border-color: #f2dcb3; color: #92510a; } | |
| 120 | + .box.r { background: var(--red-bg); border-color: #f3c6c6; color: #a11d1d; } | |
| 121 | + .box small { display: block; font-size: 11px; opacity: .8; margin-top: 1px; } | |
| 122 | + .conn { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-size: 12px; flex-direction: column; padding: 0 2px; min-width: 54px; } | |
| 123 | + .conn .arrow { font-size: 20px; line-height: 1; color: #9fb0c4; } | |
| 124 | + | |
| 125 | + /* 流程条 */ | |
| 126 | + .flow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 10px 0; } | |
| 127 | + .flow .step { | |
| 128 | + background: #fff; border: 1.5px solid var(--line); border-radius: 10px; | |
| 129 | + padding: 8px 13px; font-size: 13px; color: var(--ink-2); text-align: center; line-height: 1.45; | |
| 130 | + } | |
| 131 | + .flow .step b { display: block; font-size: 13px; color: var(--ink); } | |
| 132 | + .flow .step.hl { border-color: #c4d5f5; background: var(--blue-bg); } | |
| 133 | + .flow .step.gate { border-color: #ddd0fa; background: var(--purple-bg); } | |
| 134 | + .flow .step.stop { border-color: #f2dcb3; background: var(--amber-bg); } | |
| 135 | + .flow .step.done { border-color: #b6e6d3; background: var(--green-bg); } | |
| 136 | + .flow .fa { color: #9fb0c4; font-size: 17px; } | |
| 137 | + | |
| 138 | + /* 状态机 */ | |
| 139 | + .fsm { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: center; margin: 12px 0; } | |
| 140 | + .fsm .st { border-radius: 10px; padding: 10px 16px; text-align: center; border: 1.8px solid; font-size: 13px; line-height: 1.5; } | |
| 141 | + .fsm .st b { font-size: 14px; display: block; } | |
| 142 | + .fsm .st small { font-size: 11.5px; display: block; opacity: .85; } | |
| 143 | + .fsm .pend { border-color: #c4d5f5; background: var(--blue-bg); color: #1e40af; } | |
| 144 | + .fsm .succ { border-color: #b6e6d3; background: var(--green-bg); color: #066a4a; } | |
| 145 | + .fsm .fail { border-color: #f3c6c6; background: var(--red-bg); color: #a11d1d; } | |
| 146 | + .fsm .fa { color: #9fb0c4; font-size: 18px; } | |
| 147 | + | |
| 148 | + /* 记忆分段 */ | |
| 149 | + .memseg { display: flex; flex-direction: column; gap: 7px; margin: 10px 0; } | |
| 150 | + .memseg .seg { border-radius: 10px; padding: 8px 15px; font-size: 13.5px; border: 1.5px solid; } | |
| 151 | + .memseg .seg b { display: inline-block; margin-right: 8px; } | |
| 152 | + .memseg .tag { float: right; font-size: 11.5px; opacity: .75; } | |
| 153 | + .memseg .s1 { border-color: #c4d5f5; background: var(--blue-bg); color: #1e40af; } | |
| 154 | + .memseg .s2 { border-color: #ddd0fa; background: var(--purple-bg); color: #5b21b6; } | |
| 155 | + .memseg .s3 { border-color: #f2dcb3; background: var(--amber-bg); color: #92510a; } | |
| 156 | + .memseg .s4 { border-color: #b5e2dd; background: var(--teal-bg); color: #0b6b62; } | |
| 157 | + .memseg .s5 { border-color: #b6e6d3; background: var(--green-bg); color: #066a4a; } | |
| 158 | + | |
| 159 | + footer { color: var(--ink-3); font-size: 12.5px; text-align: center; padding: 26px 0 40px; border-top: 1px solid var(--line); } | |
| 160 | + @media (max-width: 720px) { .lane-row { min-width: 0; flex-direction: column; } .conn { transform: rotate(90deg); padding: 6px 0; } } | |
| 161 | +</style> | |
| 162 | +</head> | |
| 163 | +<body> | |
| 164 | + | |
| 165 | +<header class="hero"> | |
| 166 | + <div class="wrap"> | |
| 167 | + <h1>xlyAi Agent 架构报告</h1> | |
| 168 | + <p class="sub">印刷 ERP 智能助手 —— 单 ReAct Agent · 技能驱动 · 模型全只读 · 人在环写入</p> | |
| 169 | + <p class="sub">依据 agent-main 分支当前代码编写,与设计文档不一致处以代码为准。</p> | |
| 170 | + <div class="meta"> | |
| 171 | + <span>Java / LangChain4j</span> | |
| 172 | + <span>本地 27B 模型(数据不出网)</span> | |
| 173 | + <span>MySQL + Redis</span> | |
| 174 | + <span>7 个工具 · 5 项技能</span> | |
| 175 | + <span>2026-07-29</span> | |
| 176 | + </div> | |
| 177 | + </div> | |
| 178 | +</header> | |
| 179 | + | |
| 180 | +<nav class="toc"> | |
| 181 | + <div class="wrap"> | |
| 182 | + <a href="#p1">一、Agent 设计</a> | |
| 183 | + <a href="#p2">二、核心机制</a> | |
| 184 | + <a href="#p3">三、AI ⇄ ERP 协作</a> | |
| 185 | + <a href="#p4">四、质量与风险</a> | |
| 186 | + </div> | |
| 187 | +</nav> | |
| 188 | + | |
| 189 | +<main> | |
| 190 | + | |
| 191 | +<!-- ================================================================ Part 1 --> | |
| 192 | +<section class="part" id="p1"> | |
| 193 | + <div class="wrap"> | |
| 194 | + <div class="part-head"><span class="no">PART 1</span><h2>Agent 的设计</h2></div> | |
| 195 | + <p class="lead">xlyAi 是 ERP 之上的「理解与编排」层:听懂业务人员的话,替他们在 1700 多张表单里查数、看单、发起操作。它是<b>一个</b>智能体(无路由、无意图分类),且<b>模型本身没有任何写入能力</b>——改数据的永远是人的一次点击。</p> | |
| 196 | + | |
| 197 | + <h3>1.1 四条设计立场</h3> | |
| 198 | + <div class="grid c2"> | |
| 199 | + <div class="card"> | |
| 200 | + <h4>① 单智能体,自己决定怎么做</h4> | |
| 201 | + <p>所有对话进同一个 ReAct 循环,模型自行决定查数、澄清、载入流程还是出卡。不设意图门——实测无准确率收益,反而在分类环节丢失原话信息。</p> | |
| 202 | + </div> | |
| 203 | + <div class="card"> | |
| 204 | + <h4>② 模型全只读,写入是人的动作</h4> | |
| 205 | + <p>模型最多把写操作渲染成一张带按钮的卡片,然后停下;真正写入走用户点按钮后的确定性路径,不经过模型。幻觉在结构上碰不到数据。</p> | |
| 206 | + </div> | |
| 207 | + <div class="card"> | |
| 208 | + <h4>③ 知识外置:流程是数据,不是代码</h4> | |
| 209 | + <p>业务流程写成技能文本存在数据库里,模型按需载入照做;改流程不用改代码发版。领域结构(域、表单、字段)来自离线物化的知识图谱。</p> | |
| 210 | + </div> | |
| 211 | + <div class="card"> | |
| 212 | + <h4>④ 薄层:业务权威永远在 ERP</h4> | |
| 213 | + <p>读走 ERP 与网页相同的 API,写只投递到一张队列表由 ERP 执行。AI 层不复制业务规则,越薄越不会与 ERP 真实行为漂移。</p> | |
| 214 | + </div> | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + <h3>1.2 Agent 解剖</h3> | |
| 218 | + <div class="diagram"> | |
| 219 | + <div class="title">大 脑 · 知 识 · 能 力 · 记 忆 · 边 界</div> | |
| 220 | + <div class="lane-row"> | |
| 221 | + <div class="lane"> | |
| 222 | + <div class="lane-title">知识</div> | |
| 223 | + <div class="box t">业务域地图<small>11 个域及流转,常驻提示词</small></div> | |
| 224 | + <div class="box t">技能库<small>流程 playbook,按需载入</small></div> | |
| 225 | + <div class="box t">知识图谱<small>表单目录 · 字段字典</small></div> | |
| 226 | + </div> | |
| 227 | + <div class="conn"><span class="arrow">→</span></div> | |
| 228 | + <div class="lane" style="border-color:#c4d5f5;"> | |
| 229 | + <div class="lane-title">大脑 + 循环</div> | |
| 230 | + <div class="box b">本地 27B 模型(低温度)</div> | |
| 231 | + <div class="box b">ReAct 工具循环<small>自选工具与时机,最多 8 步</small></div> | |
| 232 | + <div class="box b">唯一 system prompt<small>角色+域地图+技能索引+硬规则</small></div> | |
| 233 | + <div class="box r">确定性护栏<small>反编造重试 · 谎报纠正</small></div> | |
| 234 | + </div> | |
| 235 | + <div class="conn"><span class="arrow">→</span></div> | |
| 236 | + <div class="lane"> | |
| 237 | + <div class="lane-title">能力(全部只读/只渲染)</div> | |
| 238 | + <div class="box g">查:找表单 · 读数据 · 查记录</div> | |
| 239 | + <div class="box p">问:澄清小问题(可点选项)</div> | |
| 240 | + <div class="box p">请求核准:弹表单 / 出预览卡<small>出卡即停,等人点按钮</small></div> | |
| 241 | + </div> | |
| 242 | + </div> | |
| 243 | + <div class="lane-row" style="margin-top:10px;"> | |
| 244 | + <div class="lane" style="flex: 0 1 50%;"> | |
| 245 | + <div class="lane-title">记忆</div> | |
| 246 | + <div class="box t">会话事件账本(唯一事实源)</div> | |
| 247 | + <div class="box t">按 token 预算投影成上下文(见 2.3)</div> | |
| 248 | + </div> | |
| 249 | + <div class="lane" style="flex: 0 1 50%;"> | |
| 250 | + <div class="lane-title">边界</div> | |
| 251 | + <div class="box a">身份:ERP token 透传 + 服务端内省,失败即拒</div> | |
| 252 | + <div class="box a">权限:表单级白名单 + 租户隔离,随每次调用注入</div> | |
| 253 | + </div> | |
| 254 | + </div> | |
| 255 | + <p class="caption">权限不是模型要遵守的规则,而是注入在每个工具里、它无法越过的边界。</p> | |
| 256 | + </div> | |
| 257 | + | |
| 258 | + <h3>1.3 一轮对话的生命周期</h3> | |
| 259 | + <div class="diagram"> | |
| 260 | + <div class="flow"> | |
| 261 | + <div class="step"><b>用户发话</b>自由文本</div> | |
| 262 | + <div class="fa">→</div> | |
| 263 | + <div class="step hl"><b>确定身份</b>token 换真实身份与权限<br>失败即拒</div> | |
| 264 | + <div class="fa">→</div> | |
| 265 | + <div class="step hl"><b>组装上下文</b>提示词 + 流程卡 + 记忆投影</div> | |
| 266 | + <div class="fa">→</div> | |
| 267 | + <div class="step hl"><b>ReAct 循环</b>载技能 / 查数 / 澄清 / 出卡</div> | |
| 268 | + <div class="fa">→</div> | |
| 269 | + <div class="step gate"><b>护栏检查</b>没查数报数字→重试<br>谎称已保存→纠正</div> | |
| 270 | + <div class="fa">→</div> | |
| 271 | + <div class="step done"><b>收尾</b>给出答复,或出卡即停</div> | |
| 272 | + </div> | |
| 273 | + <p class="caption">全程流式推送;每个事件按序落入账本,成为下一轮记忆。用户点了卡片按钮,则进入第二部分的确定性写路径。</p> | |
| 274 | + </div> | |
| 275 | + | |
| 276 | + <h3>1.4 能力面:七个工具,四种角色</h3> | |
| 277 | + <div class="note green"><b>工具集固定且极小</b>:所有轮次共用同一组 7 个工具与同一份提示词。能力面越小,小模型的工具选择越稳,提示前缀稳定也利于推理缓存。</div> | |
| 278 | + <table> | |
| 279 | + <tr><th>角色</th><th>工具</th><th>做什么 / 为什么</th></tr> | |
| 280 | + <tr><td><span class="pill info">载入流程</span></td><td><code>useSkill</code></td><td>载入某技能全文。提示词只放一行索引,全文按需加载;载入后钉在流程卡上跨轮生效。</td></tr> | |
| 281 | + <tr><td rowspan="3"><span class="pill ro">感知查数</span></td><td><code>findForms</code></td><td>「某类单据」→ 具体哪张表单。模型不必背 1748 张表结构。</td></tr> | |
| 282 | + <tr><td><code>readFormData</code></td><td>读某表单一页数据(可过滤、翻页)。答案里的每个数字都必须出自这里,输出截断分页。</td></tr> | |
| 283 | + <tr><td><code>lookupRecord</code></td><td>某条命名记录的完整字段。多条命中报候选让用户挑,不猜。</td></tr> | |
| 284 | + <tr><td><span class="pill hitl">对话交互</span></td><td><code>askUser</code></td><td>一个澄清问题 + 可点选项。缺关键信息才问、一次问全、问完即停。</td></tr> | |
| 285 | + <tr><td rowspan="2"><span class="pill hitl">请求核准</span></td><td><code>collectForm</code></td><td>新增:弹一张表单让用户一次填齐;已说的值预填,客户/产品从真实数据下拉选。</td></tr> | |
| 286 | + <tr><td><code>previewChange</code></td><td>改/作废/审核等:出预览卡(当前值 + 变化高亮 + ERP 同名按钮)。人核对的就是将要写入的。</td></tr> | |
| 287 | + </table> | |
| 288 | + | |
| 289 | + <h3>1.5 知识体系三层</h3> | |
| 290 | + <div class="grid c3"> | |
| 291 | + <div class="card"> | |
| 292 | + <h4>域地图(常驻)</h4> | |
| 293 | + <p>11 个业务域及上下游流转,每域一行常驻提示词——给模型一张「问题落在哪」的粗地图。</p> | |
| 294 | + </div> | |
| 295 | + <div class="card"> | |
| 296 | + <h4>技能(按需载入)</h4> | |
| 297 | + <p>每篇只写步骤与停止条件,不写错例枚举。内置五篇:查询、新建报价、新建单据、修改记录、单据状态操作。</p> | |
| 298 | + </div> | |
| 299 | + <div class="card"> | |
| 300 | + <h4>知识图谱(工具背后)</h4> | |
| 301 | + <p>从 ERP 元数据物化的表单目录与字段字典(中文名⇄技术列⇄外键)。模型不直接看它,但每次定位与解析都踩在它上面。</p> | |
| 302 | + </div> | |
| 303 | + </div> | |
| 304 | + | |
| 305 | + <h3>1.6 环境</h3> | |
| 306 | + <div class="diagram"> | |
| 307 | + <div class="lane-row"> | |
| 308 | + <div class="lane"> | |
| 309 | + <div class="lane-title">浏览器</div> | |
| 310 | + <div class="box">聊天流 + 卡片/表单/选项片</div> | |
| 311 | + <div class="box">多命名会话侧栏</div> | |
| 312 | + </div> | |
| 313 | + <div class="conn"><span class="arrow">⇄</span><span>流式对话<br>ERP 登录态透传</span></div> | |
| 314 | + <div class="lane" style="border-color:#c4d5f5;"> | |
| 315 | + <div class="lane-title">xlyAi(本报告主角)</div> | |
| 316 | + <div class="box b">单 ReAct Agent</div> | |
| 317 | + <div class="box p">确定性保存端点(人在环写)</div> | |
| 318 | + <div class="box a">授权层(补 ERP 缺失的表单级权限)</div> | |
| 319 | + </div> | |
| 320 | + <div class="conn"><span class="arrow">⇄</span><span>只读 API<br>+ 共享队列表</span></div> | |
| 321 | + <div class="lane"> | |
| 322 | + <div class="lane-title">ERP 后端(业务权威)</div> | |
| 323 | + <div class="box">表单读 API · 身份内省 · 字段候选</div> | |
| 324 | + <div class="box a">写操作执行器 + AI 待办 + 状态回执</div> | |
| 325 | + </div> | |
| 326 | + </div> | |
| 327 | + <p class="caption">支撑设施:MySQL(与 ERP 共库,多租户)、Redis(缓存/暂存)、本地 27B 模型(OpenAI 兼容通道)。接口细节见第三部分,实现映射见附录。</p> | |
| 328 | + </div> | |
| 329 | + </div> | |
| 330 | +</section> | |
| 331 | + | |
| 332 | +<!-- ================================================================ Part 2 --> | |
| 333 | +<section class="part" id="p2"> | |
| 334 | + <div class="wrap"> | |
| 335 | + <div class="part-head"><span class="no">PART 2</span><h2>核心机制</h2></div> | |
| 336 | + <p class="lead">支撑上述设计的四条机制:写路径的层层闸门、事件账本记忆、以及针对小模型不可靠性的确定性护栏。</p> | |
| 337 | + | |
| 338 | + <h3>2.1 编排:技能文本代替路由与流水线</h3> | |
| 339 | + <div class="card"> | |
| 340 | + <ul> | |
| 341 | + <li><b>规则只有一条</b>:全部自由文本进唯一 ReAct agent,流程知识由模型按需 useSkill 载入。</li> | |
| 342 | + <li><b>不做意图门</b>:分类器不掌握字段定义,抽取必有损(「大16开」→「16开」);原话只应在字段定义存在的地方被解释一次。</li> | |
| 343 | + <li><b>跨轮续办靠流程卡</b>:激活的技能全文钉在提示词尾部,流程终结才摘下——旧轮被压缩也不丢步骤。</li> | |
| 344 | + </ul> | |
| 345 | + </div> | |
| 346 | + | |
| 347 | + <h3>2.2 写路径:三道闸</h3> | |
| 348 | + <div class="note"><b>LLM 说什么都改不了数据库。</b>能写的只有两个确定性端点,且最多写一行待办——执行在 ERP 侧。</div> | |
| 349 | + <div class="diagram"> | |
| 350 | + <div class="title">以「修 改 记 录」为 例</div> | |
| 351 | + <div class="flow"> | |
| 352 | + <div class="step"><b>用户</b>「把 XX 单数量改成 3000」</div> | |
| 353 | + <div class="fa">→</div> | |
| 354 | + <div class="step hl"><b>模型(只读)</b>载技能 → previewChange</div> | |
| 355 | + <div class="fa">→</div> | |
| 356 | + <div class="step gate"><b>闸① 预览卡</b>定位 + 校验<br>解析产物暂存服务端</div> | |
| 357 | + <div class="fa">→</div> | |
| 358 | + <div class="step stop"><b>模型停手</b>等用户核对</div> | |
| 359 | + </div> | |
| 360 | + <div class="flow"> | |
| 361 | + <div class="step"><b>用户点【保存】</b></div> | |
| 362 | + <div class="fa">→</div> | |
| 363 | + <div class="step gate"><b>闸② 确定性端点</b>归属/权限重查 → <b>重读记录重校验</b><br>→ 原子抢占防重复提交</div> | |
| 364 | + <div class="fa">→</div> | |
| 365 | + <div class="step gate"><b>闸③ 入队</b>ai_op_queue 一行 pending</div> | |
| 366 | + <div class="fa">→</div> | |
| 367 | + <div class="step done"><b>ERP 执行</b>报价自动执行<br>其余进待办</div> | |
| 368 | + </div> | |
| 369 | + </div> | |
| 370 | + <div class="grid c2"> | |
| 371 | + <div class="card"> | |
| 372 | + <h4>所见即所写</h4> | |
| 373 | + <p>保存时重读记录:被他人改过的值、变了的审核/作废状态一律拒绝,请重新预览。卡上显示的 FK 是实际解析绑定的记录名、数字是规范化后的值——人核对的就是入库的。</p> | |
| 374 | + </div> | |
| 375 | + <div class="card"> | |
| 376 | + <h4>状态合法性硬检查</h4> | |
| 377 | + <p>审核要求未审核、作废要求未作废等前置条件在预览与保存各查一次,ERP 执行时刻再查一次,三层防竞态。</p> | |
| 378 | + </div> | |
| 379 | + <div class="card"> | |
| 380 | + <h4>新增走表单</h4> | |
| 381 | + <p>用户填完点保存后由端点构建载荷:中文名→列、FK 名称→id、类型强转(解析不了的数字报错而非写 0)、补必填默认、生成主键与单号。报价单构建主表+从表+多数量三张表,价格留给 ERP 核价。</p> | |
| 382 | + </div> | |
| 383 | + <div class="card"> | |
| 384 | + <h4>防重复防并发</h4> | |
| 385 | + <p>previewId 一次性、原子抢占,并发点击只有一个能入队;多字段先全部过校验再单事务入队,绝不写一半。</p> | |
| 386 | + </div> | |
| 387 | + </div> | |
| 388 | + | |
| 389 | + <h3>2.3 记忆:事件账本 + 四段投影</h3> | |
| 390 | + <div class="card"> | |
| 391 | + <p>会话没有可被并发覆盖的「聊天记录数组」:每句话、每次工具调用、每次按钮点击都是一条<b>追加事件</b>,落 MySQL(Redis 热缓存)。前端历史与 LLM 上下文是同一账本的两种投影,永不对不上。喂给模型的上下文按 token 预算分段组装:</p> | |
| 392 | + <div class="memseg"> | |
| 393 | + <div class="seg s1"><b>① system prompt</b><span class="tag">永不让位</span></div> | |
| 394 | + <div class="seg s2"><b>② 进行中的流程卡</b>激活技能 + 在办单据状态<span class="tag">永不让位</span></div> | |
| 395 | + <div class="seg s3"><b>④ 往事摘要区</b>预算外旧轮 → 每轮一行确定性摘要<span class="tag">小额预算</span></div> | |
| 396 | + <div class="seg s4"><b>⑤ 近期原文区</b>整轮纳入,旧轮工具结果压成短摘要<span class="tag">预算内尽量装</span></div> | |
| 397 | + <div class="seg s5"><b>当前轮</b>原样保真,超限时先压最早的工具结果<span class="tag">永不让位</span></div> | |
| 398 | + </div> | |
| 399 | + <p>发送前自检总量,超了继续收缩——绝不让推理端从头部静默截断(那样 system prompt 先死)。估算故意偏高,并与模型返回的实际 token 数持续对账。</p> | |
| 400 | + </div> | |
| 401 | + | |
| 402 | + <h3>2.4 针对小模型的确定性护栏</h3> | |
| 403 | + <div class="grid c2"> | |
| 404 | + <div class="card"><h4>反编造</h4><p>零工具调用却答出数字 → 注入纠正话术自动重试一次;复发则标注「未经核实」。</p></div> | |
| 405 | + <div class="card"><h4>谎报纠正</h4><p>声称「已保存/已完成」但本轮没出过卡 → 追加提示「没有卡片即没有任何写入」。</p></div> | |
| 406 | + <div class="card"><h4>确定性解析</h4><p>尺寸拆分(「50*30*5」→长/宽/高)、数字/日期强转、FK 消歧、单号生成全在代码层——模型负责理解意图,代码负责解释取值。</p></div> | |
| 407 | + <div class="card"><h4>资源护栏</h4><p>工具循环最多 8 步、超时 180 秒、所有工具输出分页/截断。</p></div> | |
| 408 | + </div> | |
| 409 | + | |
| 410 | + <h3>2.5 安全红线</h3> | |
| 411 | + <table> | |
| 412 | + <tr><th>不变量</th><th>一句话</th></tr> | |
| 413 | + <tr><td><b>身份 fail-closed</b></td><td>身份只来自 ERP 服务端内省;token 无效即 401,绝不回落管理员身份。</td></tr> | |
| 414 | + <tr><td><b>token 不进模型</b></td><td>用户 token 仅单次请求持有,绝不进提示词、账本、暂存。</td></tr> | |
| 415 | + <tr><td><b>表单级白名单</b></td><td>读/写/下拉共用同一权限边界;鉴权参数用服务端反查值,不信模型自报。</td></tr> | |
| 416 | + <tr><td><b>租户隔离</b></td><td>所有查询强制品牌谓词,拿不到租户宁可返回空。</td></tr> | |
| 417 | + <tr><td><b>会话隔离</b></td><td>会话 id 强制用户命名空间——伪造他人 id 只会落到自己名下。</td></tr> | |
| 418 | + <tr><td><b>注入防护</b></td><td>URL id 白名单校验、SQL 全参数化、CORS 白名单。</td></tr> | |
| 419 | + <tr><td><b>读不变写</b></td><td>读接口只传分页/过滤参数,绝不透传 ERP 存在的写类参数。</td></tr> | |
| 420 | + </table> | |
| 421 | + </div> | |
| 422 | +</section> | |
| 423 | + | |
| 424 | +<!-- ================================================================ Part 3 --> | |
| 425 | +<section class="part" id="p3"> | |
| 426 | + <div class="wrap"> | |
| 427 | + <div class="part-head"><span class="no">PART 3</span><h2>AI 侧 ⇄ ERP 侧:协作与接口</h2></div> | |
| 428 | + <p class="lead">分界线一句话:<b>xlyAi 负责理解、定位、校验、请求核准;ERP 负责执行、审计、权属、回执。</b>交接靠同步的薄 HTTP 接口 + 异步的共享队列表。</p> | |
| 429 | + | |
| 430 | + <h3>3.1 HTTP 接口(xlyAi → ERP,全部透传用户 token)</h3> | |
| 431 | + <table> | |
| 432 | + <tr><th>接口</th><th>用途</th></tr> | |
| 433 | + <tr><td><code>GET /ai/whoami</code></td><td>token → 服务端认定的身份与租户,所有端点的身份唯一来源。</td></tr> | |
| 434 | + <tr><td><code>POST /business/getBusinessDataByFormcustomId</code></td><td>表单数据读取,与网页前端同一接口,天然带租户与行级权限。</td></tr> | |
| 435 | + <tr><td><code>GET /getUuid</code></td><td>新增载荷的主键由 ERP 发号。</td></tr> | |
| 436 | + <tr><td><code>POST /business/checkBusinessData</code></td><td>dry-run 预检:复用 ERP 真实校验链、强制回滚,把「保存了才发现不合法」提前。</td></tr> | |
| 437 | + <tr><td><code>POST /ai/fieldOptions</code></td><td>FK 下拉候选,按 ERP 下拉配置取——带行级权限、级联过滤、联动回填;不可用时回落本地字典。</td></tr> | |
| 438 | + </table> | |
| 439 | + | |
| 440 | + <h3>3.2 ai_op_queue:交接面与状态协议</h3> | |
| 441 | + <div class="card"> | |
| 442 | + <p>用户点按钮后 xlyAi 写一行操作请求(类型 + 目标 + 载荷 + 描述),状态恒为 <b>pending+100</b>;领取、执行、幂等、审计、回写全在 ERP 侧。现行策略:<b>报价自动执行,其余进「我的 AI 待办」</b>。xlyAi 只把状态只读展示在流程卡上,绝不重复提交。</p> | |
| 443 | + <div class="fsm"> | |
| 444 | + <div class="st pend"><b>pending</b><small>100 已提交(xlyAi 唯一写入)</small><small>101 ERP 处理中</small></div> | |
| 445 | + <div class="fa">→</div> | |
| 446 | + <div class="st succ"><b>success</b><small>200 成功 · 201 幂等命中</small></div> | |
| 447 | + <div class="fa" style="margin: 0 10px;">/</div> | |
| 448 | + <div class="st fail"><b>fail</b><small>400 载荷 · 401 权限 · 404 不存在 · 409 状态冲突</small><small>422 业务校验 · 423 锁定 · 450 取消 · 500/504</small></div> | |
| 449 | + </div> | |
| 450 | + <ul> | |
| 451 | + <li>xlyAi 只写 pending+100,其余状态只由 ERP 回写。</li> | |
| 452 | + <li>一次保存多字段 = 多行待办(单事务);ERP 执行时刻再验状态,冲突回 409。</li> | |
| 453 | + </ul> | |
| 454 | + </div> | |
| 455 | + | |
| 456 | + <div class="diagram"> | |
| 457 | + <div class="title">职 责 分 界</div> | |
| 458 | + <div class="lane-row"> | |
| 459 | + <div class="lane" style="border-color:#c4d5f5;"> | |
| 460 | + <div class="lane-title">xlyAi</div> | |
| 461 | + <div class="box b">理解意图 · 载入技能 · 定位表单与记录</div> | |
| 462 | + <div class="box b">校验:字段/类型/FK/权限/状态/租户</div> | |
| 463 | + <div class="box p">渲染卡片,等人点按钮</div> | |
| 464 | + <div class="box a">入队 pending —— <b>到此为止</b></div> | |
| 465 | + </div> | |
| 466 | + <div class="conn"><span class="arrow">→</span><span>ai_op_queue</span></div> | |
| 467 | + <div class="lane"> | |
| 468 | + <div class="lane-title">ERP 侧</div> | |
| 469 | + <div class="box">执行器以用户身份复用 ERP 原生写入/审核逻辑</div> | |
| 470 | + <div class="box">幂等 · 执行时刻校验 · 单号执行前重新生成</div> | |
| 471 | + <div class="box">审计留痕 · 状态回写 · AI 待办入口</div> | |
| 472 | + </div> | |
| 473 | + </div> | |
| 474 | + </div> | |
| 475 | + | |
| 476 | + <h3>3.3 身份与授权协作</h3> | |
| 477 | + <div class="grid c2"> | |
| 478 | + <div class="card"> | |
| 479 | + <h4>凭证透传,不自造身份</h4> | |
| 480 | + <p>xlyAi 不签发、不存储、不续期任何用户凭证:前端逐请求带 ERP token,xlyAi 原样转发。会话按稳定身份存,重登录换 token 后历史接得回。</p> | |
| 481 | + </div> | |
| 482 | + <div class="card"> | |
| 483 | + <h4>授权互补</h4> | |
| 484 | + <p>ERP 后端只强制租户 + 行级两层,表单级权限校验处于关闭状态(仅前端 UI 用)。xlyAi 用同一份权限数据在自己侧补回这层,作为纵深防御;根因整改待业务决策。</p> | |
| 485 | + </div> | |
| 486 | + </div> | |
| 487 | + </div> | |
| 488 | +</section> | |
| 489 | + | |
| 490 | +<!-- ================================================================ Part 4 --> | |
| 491 | +<section class="part" id="p4"> | |
| 492 | + <div class="wrap"> | |
| 493 | + <div class="part-head"><span class="no">PART 4</span><h2>质量保障 · 残余风险 · 设计取舍</h2></div> | |
| 494 | + | |
| 495 | + <h3>4.1 质量保障</h3> | |
| 496 | + <div class="grid c3"> | |
| 497 | + <div class="card"><h4>基准评测</h4><p>50 题冻结基线 + 多轮轨迹基准(消歧/接续/夹带/回指),与生产读同一份提示词和技能文件。当前:扩展集 12/12,全轨迹 84%。</p></div> | |
| 498 | + <div class="card"><h4>单元测试</h4><p>8 个测试类钉住不变量:fail-closed、会话归属、投影正确性、并发不丢事件、强转拒非法值等。</p></div> | |
| 499 | + <div class="card"><h4>对抗性审计</h4><p>多智能体并行审计 + 独立复核证否,最近一次全量复审 CRITICAL 0 / HIGH 3,HIGH 已全部修复回归。</p></div> | |
| 500 | + </div> | |
| 501 | + <div class="card"><h4>可观测性</h4><p>每次 LLM 调用记录耗时/token/错误,估算与实际 token 持续对账;可选导出自托管 Langfuse(数据不出网)。业务审计随执行权在 ERP 侧。</p></div> | |
| 502 | + | |
| 503 | + <h3>4.2 残余风险(交付说明)</h3> | |
| 504 | + <table> | |
| 505 | + <tr><th>级别</th><th>事项</th><th>一句话</th></tr> | |
| 506 | + <tr><td><span class="pill risk">安全</span></td><td>技能表是提示词注入面</td><td>谁能写 ai_skill 谁就能改 agent 行为,需收紧表写权限。</td></tr> | |
| 507 | + <tr><td><span class="pill risk">安全</span></td><td>dry-run 失败放行</td><td>刻意取舍:预检挂了不挡用户,ERP 执行时仍会真校验。</td></tr> | |
| 508 | + <tr><td><span class="pill warn">安全</span></td><td>token URL 通道 / 生产前置校验</td><td>壳层嵌入的 token 传递与 nginx 前置会话校验属环境决策项。</td></tr> | |
| 509 | + <tr><td><span class="pill warn">运维</span></td><td>账本永久保留</td><td>append-only 永不删除,需定保留/归档策略。</td></tr> | |
| 510 | + <tr><td><span class="pill warn">协作</span></td><td>队列旧列待删 / 生产迁移待执行</td><td>等 ERP 去掉兼容读后删列;4 份迁移 SQL 待上生产。</td></tr> | |
| 511 | + <tr><td><span class="pill warn">协作</span></td><td>「处理中」卡死无恢复</td><td>ERP 领取后异常退出会停在 101,需 ERP 侧超时补偿。</td></tr> | |
| 512 | + <tr><td><span class="pill info">根因</span></td><td>ERP 权限根因未整改</td><td>xlyAi 的白名单是纵深防御,不替代后端重开表单级校验。</td></tr> | |
| 513 | + <tr><td><span class="pill info">功能</span></td><td>无语义召回</td><td>表单定位全靠关键词匹配,模糊说法要换词重试。</td></tr> | |
| 514 | + <tr><td><span class="pill info">功能</span></td><td>策展字段仅报价有</td><td>其他复杂主-从单据的新增走字典启发式,体验有限。</td></tr> | |
| 515 | + </table> | |
| 516 | + | |
| 517 | + <h3>4.3 贯穿全局的取舍</h3> | |
| 518 | + <ul> | |
| 519 | + <li><b>不引编排框架</b>:人在环用「出卡即停 + 确定性端点」实现,比中断/续跑机制更简单,也更安全——模型根本没有写工具。</li> | |
| 520 | + <li><b>可靠性放代码不放提示词</b>:不写逐案纠错规则,靠确定性解析 + 廉价恢复 + 回归基准。</li> | |
| 521 | + <li><b>执行权交出去</b>:AI 止步于入队,写入发生在 ERP 原生路径——代码量与攻击面同时缩小。</li> | |
| 522 | + <li><b>处处幂等</b>:所有交接点都假设会被重复触发(一次性抢占、追加原子、幂等键、单号重生成)。</li> | |
| 523 | + </ul> | |
| 524 | + | |
| 525 | + <h3>附录:设计概念 → 代码映射</h3> | |
| 526 | + <table> | |
| 527 | + <tr><th>设计概念</th><th>主要代码位置</th></tr> | |
| 528 | + <tr><td>ReAct 循环 / 按请求组装</td><td><code>agent/ReActAgent</code> · <code>config/AgentFactory</code> · <code>agent/AgentIdentity</code></td></tr> | |
| 529 | + <tr><td>对话入口 / SSE / 护栏</td><td><code>web/AgentChatController</code></td></tr> | |
| 530 | + <tr><td>7 个工具</td><td><code>tool/</code>:UseSkillTool · KgQueryTool · ErpReadTool · FormCollectTool · PreviewChangeTool · InteractionTool</td></tr> | |
| 531 | + <tr><td>提示词 / 技能</td><td><code>service/SystemPromptService</code>(<code>prompts/system.txt</code>)· <code>service/SkillService</code>(<code>ai_skill</code> / <code>skills/*.md</code>)</td></tr> | |
| 532 | + <tr><td>表单/字段/FK 解析校验</td><td><code>service/FormResolverService</code> · <code>service/FormRenderService</code>(<code>viw_ai_useful_forms</code> / <code>viw_kg_field_dict</code>)</td></tr> | |
| 533 | + <tr><td>预览/保存闭环 / 唯一写入口</td><td><code>service/PreviewService</code> · <code>web/PreviewController</code> · <code>service/OpService</code>(<code>ai_op_queue</code>)</td></tr> | |
| 534 | + <tr><td>账本与记忆投影</td><td><code>service/LedgerService</code>(<code>ai_chat_event</code>)· <code>agent/EventLogChatMemory</code> · <code>service/EventProjectionService</code></td></tr> | |
| 535 | + <tr><td>身份与授权 / ERP 客户端</td><td><code>service/AuthzService</code> · <code>service/ErpClient</code></td></tr> | |
| 536 | + <tr><td>会话 / FK 选项</td><td><code>service/ConversationService</code> · <code>web/ConversationController</code> · <code>web/FormController</code></td></tr> | |
| 537 | + <tr><td>可观测性</td><td><code>config/TracingChatModelListener</code></td></tr> | |
| 538 | + </table> | |
| 539 | + </div> | |
| 540 | +</section> | |
| 541 | + | |
| 542 | +</main> | |
| 543 | + | |
| 544 | +<footer> | |
| 545 | + xlyAi Agent 架构报告 · 2026-07-29 · 依据 agent-main 分支当前代码 · 与设计文档不一致处以代码为准 | |
| 546 | +</footer> | |
| 547 | + | |
| 548 | +</body> | |
| 549 | +</html> | ... | ... |
docs/agent-architecture.md
| ... | ... | @@ -373,7 +373,12 @@ ai_op_queue( |
| 373 | 373 | |
| 374 | 374 | - **确定性表单提交**:`POST /api/agent/form/submit` 结构化字段直达 `proposeWrite(action=create)`,不经 LLM 再编码 |
| 375 | 375 | (旧的 NL 标记路径已删)。 |
| 376 | -- `GET /api/agent/form/options`:多列(名称+按使用度选 3 个展示列)+ 名称搜索 + 分页,FK 二级选择器数据源。 | |
| 376 | +- `GET /api/agent/form/options`:FK 二级选择器数据源。**优先走 ERP 的下拉控件配置** | |
| 377 | + (`POST /ai/fieldOptions`,带行级数据权限、级联过滤、联动回填映射;契约见 | |
| 378 | + `docs/erp-tasks-field-options.md`),端点未上线/该字段无配置/异常时回落本地字典查询 | |
| 379 | + (多列 = 名称 + 按使用度选 3 个展示列,名称搜索 + 分页 + 强制租户谓词)。 | |
| 380 | + 用户点中某一行后,其 id 随保存回传(`boundIds`)并被直接采信——同名记录不再有歧义, | |
| 381 | + 权限判定以 ERP 返回的候选为准;未经选择、仅凭名称的路径仍保持同名 fail-closed。 | |
| 377 | 382 | - `askUser` 返回 `allowFree=true`:前端选项片之外永远保留自由输入(Claude Code 风格)。 |
| 378 | 383 | - `chat.html` 整页重写:原生 JS(去 jQuery)、全视口、可折叠会话侧栏、单滚动区、markdown 表格、提议卡片、 |
| 379 | 384 | collectForm 网格布局 + 类型化控件 + FK 选择器模态。 | ... | ... |
docs/erp-tasks-field-options.md
| ... | ... | @@ -154,7 +154,14 @@ Authorization: <用户 token> // 与现有 /ai/* 一致,@Authorization |
| 154 | 154 | |
| 155 | 155 | ## 我方对应改动(供你们了解,不用你们做) |
| 156 | 156 | |
| 157 | -拿到这个端点后,xlyAi 的 `/api/agent/form/options` 会改成: | |
| 158 | -优先按 `(formId, 字段)` 走你们的新端点;`mode:"none"` 或网络异常时回落到现有的字典直查(保底不影响可用性)。 | |
| 159 | -`need_context` 会转成"请先选客户"的提示。`assign` 会并入创建载荷。 | |
| 160 | -涉及文件:`FormController` / `FormResolverService.fkOptionPage` / `FormRenderService.buildQuote`。 | |
| 157 | +已实现(2026-07-29):`/api/agent/form/options` 优先按 `(formId, 字段)` 走你们的新端点, | |
| 158 | +`mode:"none"`/端点未上线/网络异常时回落到本地字典直查(保底不影响可用); | |
| 159 | +`need_context` 转成"请先选择客户"的提示;FK 选择器把表单上已绑定的记录 id 作为 `context` 带上。 | |
| 160 | + | |
| 161 | +**用户在选择器里点中某一行后,我们直接采信该行的 id 写入载荷**(不再按名称反查)—— | |
| 162 | +即候选的权限、租户、级联判定完全以你们返回的结果为准。因此**候选列表里出现的记录, | |
| 163 | +就等于允许该用户绑定的记录**,请确保这两件事在你们那边是同一回事。 | |
| 164 | + | |
| 165 | +`assign` 联动回填尚未接(等端点上线后做)。 | |
| 166 | +涉及文件:`FormController` / `ErpClient.fieldOptions` / `FormRenderService.normalize|buildCreate` / | |
| 167 | +`PreviewService.save|saveCreate`。 | ... | ... |
src/main/java/com/xly/service/FormRenderService.java
| ... | ... | @@ -251,6 +251,10 @@ public class FormRenderService { |
| 251 | 251 | public String shown; |
| 252 | 252 | } |
| 253 | 253 | |
| 254 | + private static String boundIdOf(Map<String, String> boundIds, String col) { | |
| 255 | + return boundIds == null ? null : boundIds.get(col); | |
| 256 | + } | |
| 257 | + | |
| 254 | 258 | /** |
| 255 | 259 | * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、 |
| 256 | 260 | * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在 |
| ... | ... | @@ -258,12 +262,27 @@ public class FormRenderService { |
| 258 | 262 | * 预览与保存共用同一份逻辑;FK 的 {@code shown} = **实际解析到的记录名**("所见即所写")。 |
| 259 | 263 | */ |
| 260 | 264 | public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { |
| 265 | + return normalize(table, col, fk, label, value, null, identity); | |
| 266 | + } | |
| 267 | + | |
| 268 | + /** | |
| 269 | + * 同上;{@code boundId} 非空时表示用户是在**选择器里点中了具体一行**(候选由 ERP 按其下拉配置给出, | |
| 270 | + * 权限/租户/级联均已在那里裁定),此时直接采信该 id,不再按名称反查——同名记录因此不再有歧义。 | |
| 271 | + */ | |
| 272 | + public Normalized normalize(String table, String col, String fk, String label, String value, | |
| 273 | + String boundId, AgentIdentity identity) { | |
| 261 | 274 | Normalized n = new Normalized(); |
| 262 | 275 | Map<String, String> types = resolver.columnTypes(table); |
| 263 | 276 | if (!types.containsKey(col)) { |
| 264 | 277 | n.error = "「" + label + "」不是「" + table + "」的可写字段,请换个字段名。"; |
| 265 | 278 | return n; |
| 266 | 279 | } |
| 280 | + if (fk != null && !fk.isBlank() && boundId != null && !boundId.isBlank()) { | |
| 281 | + n.stored = boundId.trim(); | |
| 282 | + String name = fkDisplayName(fk, boundId.trim()); | |
| 283 | + n.shown = name == null || name.isBlank() || name.equals(boundId.trim()) ? value : name; | |
| 284 | + return n; | |
| 285 | + } | |
| 267 | 286 | if (fk != null && !fk.isBlank()) { |
| 268 | 287 | List<Map<String, Object>> cands = resolver.resolveFkCandidates(fk, identity.brandsId(), value, 5); |
| 269 | 288 | if (cands.isEmpty()) { |
| ... | ... | @@ -358,6 +377,12 @@ public class FormRenderService { |
| 358 | 377 | * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。 |
| 359 | 378 | */ |
| 360 | 379 | public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields) { |
| 380 | + return buildCreate(identity, entityKeyword, fields, null); | |
| 381 | + } | |
| 382 | + | |
| 383 | + /** 同上;{@code boundIds}(技术列名→id)来自选择器里点中的行,FK 列据此直接绑定,不按名称反查。 */ | |
| 384 | + public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields, | |
| 385 | + Map<String, String> boundIds) { | |
| 361 | 386 | CreateBuild out = new CreateBuild(); |
| 362 | 387 | if (isBlank(entityKeyword)) { |
| 363 | 388 | out.error = "缺少单据类型。"; |
| ... | ... | @@ -376,7 +401,7 @@ public class FormRenderService { |
| 376 | 401 | return out; |
| 377 | 402 | } |
| 378 | 403 | if ("quoquotationmaster".equalsIgnoreCase(out.table)) { |
| 379 | - return buildQuote(identity, entityKeyword, fields, out); | |
| 404 | + return buildQuote(identity, entityKeyword, fields, boundIds, out); | |
| 380 | 405 | } |
| 381 | 406 | |
| 382 | 407 | Map<String, String> types = resolver.columnTypes(out.table); |
| ... | ... | @@ -404,7 +429,7 @@ public class FormRenderService { |
| 404 | 429 | continue; // 制单人/单据日期/租户/单号…由 ERP 注入 |
| 405 | 430 | } |
| 406 | 431 | String fk = fm.get("fk") == null ? null : str(fm.get("fk")); |
| 407 | - Normalized n = normalize(out.table, colName, fk, zh, v, identity); | |
| 432 | + Normalized n = normalize(out.table, colName, fk, zh, v, boundIdOf(boundIds, colName), identity); | |
| 408 | 433 | if (n.error != null) { |
| 409 | 434 | out.error = n.error + (fk != null ? "(**不要**因此代建" + zh.replace("名称", "") + ")" : ""); |
| 410 | 435 | return out; |
| ... | ... | @@ -445,7 +470,7 @@ public class FormRenderService { |
| 445 | 470 | * 价格由 ERP【核价】计算,只落主-从明细。 |
| 446 | 471 | */ |
| 447 | 472 | private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword, |
| 448 | - Map<String, String> fields, CreateBuild out) { | |
| 473 | + Map<String, String> fields, Map<String, String> boundIds, CreateBuild out) { | |
| 449 | 474 | Map<String, String> masterTypes = resolver.columnTypes(out.table); |
| 450 | 475 | Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); |
| 451 | 476 | Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); |
| ... | ... | @@ -495,7 +520,7 @@ public class FormRenderService { |
| 495 | 520 | Map<String, String> tt = "slave".equals(tgt) ? slaveTypes : masterTypes; |
| 496 | 521 | Normalized n; |
| 497 | 522 | if (fk != null && !fk.isBlank()) { |
| 498 | - n = normalize(out.table, colName, fk, zh, v, identity); | |
| 523 | + n = normalize(out.table, colName, fk, zh, v, boundIdOf(boundIds, colName), identity); | |
| 499 | 524 | if (n.error != null) { |
| 500 | 525 | out.error = n.error + "(请从下拉里选真实存在的记录;**不要**代建,也不要把产品/规格当成客户)"; |
| 501 | 526 | return out; | ... | ... |
src/main/java/com/xly/service/PreviewService.java
| ... | ... | @@ -290,6 +290,12 @@ public class PreviewService { |
| 290 | 290 | * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。 |
| 291 | 291 | */ |
| 292 | 292 | public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) { |
| 293 | + return save(who, previewId, editedFields, null); | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id),有则直接绑定不按名称反查。 */ | |
| 297 | + public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields, | |
| 298 | + Map<String, String> boundIds) { | |
| 293 | 299 | Map<String, Object> out = new LinkedHashMap<>(); |
| 294 | 300 | JsonNode draft = unstash(previewId); |
| 295 | 301 | if (draft == null) { |
| ... | ... | @@ -314,7 +320,7 @@ public class PreviewService { |
| 314 | 320 | return out; |
| 315 | 321 | } |
| 316 | 322 | return "update".equals(action) |
| 317 | - ? saveUpdate(who, draft, editedFields, cur, convId, previewId) | |
| 323 | + ? saveUpdate(who, draft, editedFields, boundIds, cur, convId, previewId) | |
| 318 | 324 | : saveStateOp(who, draft, cur, convId, action, previewId); |
| 319 | 325 | } |
| 320 | 326 | |
| ... | ... | @@ -322,7 +328,8 @@ public class PreviewService { |
| 322 | 328 | private record Change(String col, String label, String wasShown, Object stored, String shown) { } |
| 323 | 329 | |
| 324 | 330 | private Map<String, Object> saveUpdate(AgentIdentity who, JsonNode draft, Map<String, String> editedFields, |
| 325 | - FormRenderService.Located cur, String convId, String previewId) { | |
| 331 | + Map<String, String> boundIds, FormRenderService.Located cur, | |
| 332 | + String convId, String previewId) { | |
| 326 | 333 | Map<String, Object> out = new LinkedHashMap<>(); |
| 327 | 334 | String table = draft.path("table").asText(""); |
| 328 | 335 | String recordName = draft.path("recordName").asText(""); |
| ... | ... | @@ -377,7 +384,8 @@ public class PreviewService { |
| 377 | 384 | + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。"); |
| 378 | 385 | return out; |
| 379 | 386 | } |
| 380 | - FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, who); | |
| 387 | + FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, | |
| 388 | + boundIds == null ? null : boundIds.get(col), who); | |
| 381 | 389 | if (n.error != null) { |
| 382 | 390 | out.put("error", n.error); |
| 383 | 391 | return out; |
| ... | ... | @@ -482,8 +490,14 @@ public class PreviewService { |
| 482 | 490 | |
| 483 | 491 | /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */ |
| 484 | 492 | public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, Map<String, String> fields) { |
| 493 | + return saveCreate(who, convId, entity, fields, null); | |
| 494 | + } | |
| 495 | + | |
| 496 | + /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id)。 */ | |
| 497 | + public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, | |
| 498 | + Map<String, String> fields, Map<String, String> boundIds) { | |
| 485 | 499 | Map<String, Object> out = new LinkedHashMap<>(); |
| 486 | - FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields); | |
| 500 | + FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields, boundIds); | |
| 487 | 501 | if (b.error != null) { |
| 488 | 502 | out.put("error", b.error); |
| 489 | 503 | return out; | ... | ... |
src/main/java/com/xly/web/AgentChatController.java
| ... | ... | @@ -115,6 +115,7 @@ public class AgentChatController { |
| 115 | 115 | public static class FormSubmitReq { |
| 116 | 116 | public String entity; |
| 117 | 117 | public Map<String, String> fields; // 字段中文名 -> 值 |
| 118 | + public Map<String, String> boundIds; // 外键字段技术名 -> 选择器里点中的记录 id | |
| 118 | 119 | public String conversationId; |
| 119 | 120 | public String authorization; |
| 120 | 121 | } |
| ... | ... | @@ -152,7 +153,7 @@ public class AgentChatController { |
| 152 | 153 | ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity); |
| 153 | 154 | |
| 154 | 155 | try { |
| 155 | - Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields); | |
| 156 | + Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields, req.boundIds); | |
| 156 | 157 | if (r.containsKey("error")) { |
| 157 | 158 | ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity); |
| 158 | 159 | } | ... | ... |
src/main/java/com/xly/web/PreviewController.java
| ... | ... | @@ -39,6 +39,8 @@ public class PreviewController { |
| 39 | 39 | public static class SaveReq { |
| 40 | 40 | /** 可编辑预览(update)上用户最终确认的 字段技术名->值(空值=放弃该字段的改动);状态类操作可空。 */ |
| 41 | 41 | public Map<String, String> fields; |
| 42 | + /** 外键字段在选择器里点中的记录 id(字段技术名->id):有则直接绑定,不按名称反查。 */ | |
| 43 | + public Map<String, String> boundIds; | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | @PostMapping("/{id}/save") |
| ... | ... | @@ -49,6 +51,6 @@ public class PreviewController { |
| 49 | 51 | if (me == null) { |
| 50 | 52 | throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); |
| 51 | 53 | } |
| 52 | - return previews.save(me, id, req == null ? null : req.fields); | |
| 54 | + return previews.save(me, id, req == null ? null : req.fields, req == null ? null : req.boundIds); | |
| 53 | 55 | } |
| 54 | 56 | } | ... | ... |
src/main/resources/templates/chat.html
| ... | ... | @@ -666,7 +666,7 @@ function addPreviewCard(ev) { |
| 666 | 666 | const fields = {}; |
| 667 | 667 | controls.forEach(c => { fields[c.name] = c.get(); }); |
| 668 | 668 | const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save", |
| 669 | - { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) }); | |
| 669 | + { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields, boundIds: bound }) }); | |
| 670 | 670 | const d = await r.json(); |
| 671 | 671 | if (d.queued) { |
| 672 | 672 | result.className = "result ok"; |
| ... | ... | @@ -820,6 +820,7 @@ function addFormCard(ev) { |
| 820 | 820 | body: JSON.stringify({ |
| 821 | 821 | entity: ev.entity, |
| 822 | 822 | fields: fields, |
| 823 | + boundIds: bound, | |
| 823 | 824 | conversationId: conversationId |
| 824 | 825 | }) |
| 825 | 826 | }); | ... | ... |
src/test/java/com/xly/service/FormRenderNormalizeTest.java
| ... | ... | @@ -92,6 +92,28 @@ class FormRenderNormalizeTest { |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | 94 | @Test |
| 95 | + void pickedRowIdWinsOverNameLookup() { | |
| 96 | + // 用户在选择器里点中了具体一行(候选由 ERP 按其下拉配置给出,权限/租户/级联已在那里裁定): | |
| 97 | + // 直接采信该 id —— 同名多条不再有歧义,也不再走名称反查 | |
| 98 | + FormResolverService r = resolverWith(Map.of("sProductId", "varchar"), List.of( | |
| 99 | + Map.of("sId", "prodA", "name", "普通彩盒"), | |
| 100 | + Map.of("sId", "prodB", "name", "普通彩盒"))); | |
| 101 | + FormRenderService.Normalized n = svc(r).normalize( | |
| 102 | + "t", "sProductId", "eleproduct", "产品名称", "普通彩盒", "prodB", WHO); | |
| 103 | + assertNull(n.error, "点中了具体一行就不该再报同名歧义"); | |
| 104 | + assertEquals("prodB", n.stored); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + void pickedRowIdStillRejectsPhantomColumn() { | |
| 109 | + FormResolverService r = resolverWith(Map.of("dQty", "decimal"), List.of()); | |
| 110 | + FormRenderService.Normalized n = svc(r).normalize( | |
| 111 | + "t", "sGhostCol", "eleproduct", "产品名称", "普通彩盒", "prodB", WHO); | |
| 112 | + assertNotNull(n.error, "幻影列硬闸在 id 直绑路径上同样生效"); | |
| 113 | + assertNull(n.stored); | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 95 | 117 | void numericColumnStillCoerces() { |
| 96 | 118 | FormResolverService real = mock(FormResolverService.class); |
| 97 | 119 | when(real.columnTypes(anyString())).thenReturn(Map.of("dQty", "decimal")); | ... | ... |