Commit edc349ba63d26de0342c6c64982a15c391ac1a27

Authored by zichun
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 与点中行一致。
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: &lt;用户 token&gt; // 与现有 /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"));
... ...