diff --git a/bench/bench_ext.py b/bench/bench_ext.py index 85b1eb9..e93201b 100644 --- a/bench/bench_ext.py +++ b/bench/bench_ext.py @@ -27,7 +27,8 @@ MAX_STEPS = 8 # ---------------- 与 bench50_v2 相同的生产复刻 prompt / 工具定义 ---------------- import pathlib # noqa: E402 -from bench50_v2 import UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES # noqa: E402 +from bench50_v2 import (UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES, # noqa: E402 + T_FINDFORMS, T_READFORM, T_LOOKUP, T_ASK) ROOT = pathlib.Path(__file__).resolve().parent.parent SKILLS_DIR = ROOT / "src/main/resources/skills" @@ -35,10 +36,32 @@ SKILLS_DIR = ROOT / "src/main/resources/skills" T_USESKILL = fn("useSkill", "载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。", - {"skillName": {"type": "string", - "description": "技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据"}}, + {"skillName": {"type": "string", "description": "技能名(见 system prompt 的技能清单)"}}, ["skillName"]) +# rearch3:模型侧工具全部只读/只渲染——collectForm/previewChange 与生产 @Tool 文本同源 +T_COLLECT_NEW = fn("collectForm", + "在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景" + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填" + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。" + "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。", + {"entityKeyword": {"type": "string", "description": "要新建的实体/单据类型,如 报价 / 客户 / 物料"}, + "knownFieldsJson": {"type": "string", "description": "可选:用户已说的字段 JSON(中文名->值),用于预填表单"}}, + ["entityKeyword"]) +T_PREVIEW = fn("previewChange", + "为一次写操作生成**预览卡**(只读:本工具绝不写入数据,也不生成任何单据)。卡片展示记录当前内容、" + "改动或状态变化高亮,并带对应操作按钮(保存/审核/反审核/作废/取消作废/删除);只有用户点了按钮," + "系统才会提交待办。action:update=改字段(recordKeyword+fieldChinese+newValue);" + "invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废;examine=审核;" + "cancelExamine=销审/反审核;delete=物理删除(业务单据别用)。**新增不用本工具**(新增走 collectForm)。" + "本工具自行定位主表与记录,无需先 findForms。预览卡出现后你就停下等用户,绝不能说已保存/已完成。", + {"action": {"type": "string", "description": "动作:update|invalid|cancelInvalid|examine|cancelExamine|delete"}, + "entityKeyword": {"type": "string", "description": "实体/单据类型,如 报价 / 客户 / 销售订单"}, + "recordKeyword": {"type": "string", "description": "记录名称或单号关键词"}, + "fieldChinese": {"type": "string", "description": "要改的字段中文名(仅 update)"}, + "newValue": {"type": "string", "description": "新值(仅 update,逐字照抄用户原话)"}}, + ["action", "entityKeyword", "recordKeyword"]) + def load_skills(): """{名称: (一句话用途, 正文)} —— 与生产 SkillService 同源同格式(首行=名,次行=用途,余=正文)。""" @@ -51,7 +74,8 @@ def load_skills(): def build_context(arch): - """返回 (system_prompt, tools)。new 架构从仓库资源文件读取(与生产同源),避免复刻漂移。""" + """返回 (system_prompt, tools)。new 架构从仓库资源文件读取(与生产同源),避免复刻漂移。 + old = rearch1 统一 prompt + proposeWrite(历史基线);new = rearch3 skill + 只读工具(previewChange)。""" if arch == "old": return UNIFIED_PROMPT, ALL6 if arch == "new": @@ -59,7 +83,7 @@ def build_context(arch): skills = load_skills() index = "".join(f"- {name}:{brief}\n" for name, (brief, _) in skills.items()) sp = template.replace("{DOMAIN_MAP}", DOMAIN_MAP).replace("{SKILL_INDEX}", index) - return sp, [T_USESKILL] + ALL6 + return sp, [T_USESKILL, T_FINDFORMS, T_READFORM, T_LOOKUP, T_COLLECT_NEW, T_PREVIEW, T_ASK] raise SystemExit(f"未知 arch: {arch}") @@ -149,6 +173,9 @@ def fixture_result(name, args): if name == "proposeWrite": return (f"[已生成待确认提议 OP-TEST-1:action={args.get('action','')},实体={args.get('entityKeyword','')}," f"记录={args.get('recordKeyword','')}。仅提议,未执行;请用户点【确认】。]") + if name == "previewChange": + return (f"[已生成预览卡 PV-TEST-1:action={args.get('action','')},实体={args.get('entityKeyword','')}," + f"记录={args.get('recordKeyword','')}。只读预览,未写入;等待用户点卡上按钮。]") if name == "askUser": return "[问题已发给用户,等待回答,本轮到此为止。]" if name == "useSkill": @@ -206,13 +233,15 @@ GUARD_NUDGE = "你上一条回答没有调用任何工具、数字疑似编造 def run_trajectory(system_prompt, tools, history, utterance): - """带生产反编造护栏的轨迹:零工具却答出数字 → 注入纠正话术重试一次(复刻 AgentChatController)。""" + """带生产反编造护栏的轨迹:零工具却答出数字 → 注入纠正话术重试一次(复刻 AgentChatController)。 + 判分归一:previewChange(rearch3 只读预览工具)与 proposeWrite 语义同位,统一按 proposeWrite 判。""" calls, text, steps, err = run_trajectory_once(system_prompt, tools, history, utterance) - if err or calls or not text or not any(c.isdigit() for c in text): - return calls, text, steps, err - retry_hist = history + [("user", utterance), ("assistant", text)] - calls2, text2, steps2, err2 = run_trajectory_once(system_prompt, tools, retry_hist, GUARD_NUDGE + utterance) - return calls2, text2, steps + steps2, err2 + if not (err or calls or not text or not any(c.isdigit() for c in text)): + retry_hist = history + [("user", utterance), ("assistant", text)] + calls, text, steps2, err = run_trajectory_once(system_prompt, tools, retry_hist, GUARD_NUDGE + utterance) + steps += steps2 + calls = [("proposeWrite" if n == "previewChange" else n, a) for n, a in calls] + return calls, text, steps, err # ---------------- 判分辅助 ---------------- diff --git a/sql/ai_op_queue.sql b/sql/ai_op_queue.sql index 1f5cbcd..e1a384e 100644 --- a/sql/ai_op_queue.sql +++ b/sql/ai_op_queue.sql @@ -1,12 +1,15 @@ --- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 --- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 --- 覆盖 update / create / delete / examine / generate(sOpType 区分)。 --- update/delete/examine:用 sTargetBillId 指向目标记录;改字段用 sField/sNewValue。 --- create/generate:整表 payload 存 sPayload(列->值 JSON);generate 的源引用存 sSourceRef。 --- bAutoExecute=1(如报价)→ ERP 确认后自动执行并回 sResultBillId;=0 → 预填表单让用户手动提交。 +-- ai_op_queue:AI 写操作队列(rearch3 §3:**AI 侧唯一的写入口**)。 +-- 用户在预览卡/表单上点按钮(保存/审核/作废/…)→ xlyAi 校验后落一行 sStatus='confirmed'。 +-- xlyAi 工作到此为止:执行/审计由 ERP 侧负责(暂存执行器 /ai/execStaging 读本表; +-- 现状=报价自动执行、其余进待办)。覆盖 update / create / delete / examine / invalid(sOpType 区分)。 +-- update:sTargetBillId + sField/sNewValue(单字段;一次保存多字段 = 多行)。 +-- invalid:sNewValue 存 handleType(toVoid=作废 / cancel=复原);examine:sNewValue 存 iFlag(1/0)。 +-- create:整表 payload 存 sPayload(列->值 JSON,多表用 __tables__)。 +-- 列名兼容:ERP 执行器仍读 sUserId → sMakePerson(ERP 惯例)双写,ERP 侧切换后可删 sUserId。 CREATE TABLE IF NOT EXISTS ai_op_queue ( sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token - sUserId varchar(64), + sUserId varchar(64), -- 旧列(ERP 执行器在读,暂保留) + sMakePerson varchar(64), -- 新列(ERP 惯例;与 sUserId 双写) sConversationId varchar(96), sBrandsId varchar(32), sSubsidiaryId varchar(32), diff --git a/sql/ai_skill_seed.sql b/sql/ai_skill_seed.sql index b816ff1..0fd09c9 100644 --- a/sql/ai_skill_seed.sql +++ b/sql/ai_skill_seed.sql @@ -1,6 +1,6 @@ -- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。 INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-query','查询数据','查数量/概况、找记录、看单条详情、翻页时的标准查法','【技能:查询数据】\n1. 不确定该查哪张表单:先 findForms(业务关键词);同名多张时优先选检索结果靠前的那张。\n2. 问总数/概况:readFormData 且 keyword 留空;找某个名称的记录:readFormData 填 keyword;要某条记录的完整信息或某个字段(电话/销售员…):lookupRecord。\n3. 用户要下一页/更多:readFormData 同参数、page 加 1。\n4. 答案里的每个数字都必须来自本轮工具结果;工具没查到就如实说没查到。\n5. 回答用记录名称,不用内部 id。给出答案即停。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-quote-create','新建报价','用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价','【技能:新建报价】\n问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。\n1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。\n2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。\n3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。\n4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。\n5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-create','新建单据','新建报价以外的单据或资料(客户/供应商/物料/订单…)','【技能:新建单据】\n1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。\n2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。\n3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。\n4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-status','单据状态操作','作废/删除、复原、审核、反审核/销审某张已有单据','【技能:单据状态操作】\n动作对照(proposeWrite 的 action):\n- 删除/取消/不要了 → invalid(作废,可复原)\n- 复原/恢复/取消作废 → cancelInvalid\n- 审核/审核通过 → examine\n- 反审核/销审/撤回审核 → cancelExamine\n- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复\n1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。\n2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。\n3. 工具提示多条匹配:念候选让用户选后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); -INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-update','修改记录','改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段','【技能:修改记录】\n1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。\n2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。\n3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-quote-create','新建报价','用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价','【技能:新建报价】\n问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。\n1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。\n2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【保存】,停下等待。\n3. 用户点保存后系统自动校验并提交待办(上下文会出现「已提交待办」),你无需再做任何写操作;绝不说「已生成报价单」。\n4. 用户问价格数字:请他在 ERP 里点【核价】得到;你自己不报任何价格数字。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-create','新建单据','新建报价以外的单据或资料(客户/供应商/物料/订单…)','【技能:新建单据】\n1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。\n2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。\n3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。\n4. 表单弹出即停,请用户填完点【保存】;保存后系统自动校验并提交待办,你无需再做任何写操作,绝不说已完成。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-status','单据状态操作','作废/删除、复原、审核、反审核/销审某张已有单据','【技能:单据状态操作】\n动作对照(previewChange 的 action):\n- 删除/取消/不要了 → invalid(作废,可复原)\n- 复原/恢复/取消作废 → cancelInvalid\n- 审核/审核通过 → examine\n- 反审核/销审/撤回审核 → cancelExamine\n- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复\n1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。\n2. previewChange(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。该工具只生成预览卡,不写入。\n3. 工具提示多条匹配:念候选让用户选后再调一次;提示状态不合法(如已审核过):如实转告用户,停下。\n4. 预览卡出现即停:请用户核对单据与状态变化后点卡上按钮(审核/作废/…);绝不说已完成。\n消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-update','修改记录','改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段','【技能:修改记录】\n1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。\n2. 齐了就 previewChange(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。该工具只生成预览卡,不写入。\n3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。\n4. 预览卡出现即停:请用户核对整张表单(改动已高亮,可在卡上继续修正)后点【保存】;绝不说已保存/已完成。\n注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW(); diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index da79c81..3a7a304 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -6,21 +6,22 @@ import com.xly.agent.EventLogChatMemory; import com.xly.agent.ReActAgent; import com.xly.service.ErpClient; import com.xly.service.EventProjectionService; +import com.xly.service.FormRenderService; import com.xly.service.FormResolverService; import com.xly.service.LedgerService; import com.xly.service.OpService; +import com.xly.service.PreviewService; import com.xly.service.SkillService; import com.xly.service.SystemPromptService; import com.xly.tool.ErpReadTool; import com.xly.tool.FormCollectTool; import com.xly.tool.InteractionTool; import com.xly.tool.KgQueryTool; -import com.xly.tool.ProposeWriteTool; +import com.xly.tool.PreviewChangeTool; import com.xly.tool.UseSkillTool; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.service.AiServices; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; /** @@ -28,10 +29,11 @@ import org.springframework.stereotype.Component; * *

为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程, * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的 - * 工具实例里(ErpReadTool/ProposeWriteTool/FormCollectTool),从根上保证鉴权与 token 正确。 + * 工具实例里(ErpReadTool/PreviewChangeTool/FormCollectTool),从根上保证鉴权与 token 正确。 * - *

工具集**固定 6 个**(findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser), - * 不再按意图分范围:n=50 基准显示分范围收不到准确率收益,反而破坏 KV-cache 前缀稳定性。 + *

固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/previewChange/askUser。 + * **模型可用的工具全部只读/只渲染**(rearch3 §1/§3):写路径 = 预览卡/表单 → 用户点按钮 → + * 确定性端点写 ai_op_queue,模型碰不到任何写入口。 * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。 */ @Component @@ -43,9 +45,9 @@ public class AgentFactory { private final SystemPromptService systemPromptService; private final ErpClient erp; - private final JdbcTemplate jdbc; private final FormResolverService resolver; - private final OpService ops; + private final FormRenderService render; + private final PreviewService previews; private final ObjectMapper mapper; private final KgQueryTool kgQueryTool; @@ -55,7 +57,8 @@ public class AgentFactory { public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, LedgerService ledger, EventProjectionService projection, SystemPromptService systemPromptService, - ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, + ErpClient erp, FormResolverService resolver, FormRenderService render, + PreviewService previews, OpService ops, ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool, SkillService skillService) { this.streamingModel = streamingModel; @@ -63,20 +66,22 @@ public class AgentFactory { this.projection = projection; this.systemPromptService = systemPromptService; this.erp = erp; - this.jdbc = jdbc; this.resolver = resolver; - this.ops = ops; + this.render = render; + this.previews = previews; this.mapper = mapper; this.kgQueryTool = kgQueryTool; this.interactionTool = interactionTool; this.skillService = skillService; + // 流程卡的「已提交待办」行读 ai_op_queue.sStatus 只读进度(rearch3 §3) + projection.setOpStatusLookup(ops::statusLabel); } /** - * 组装 ReAct agent:固定 7 工具(useSkill + 6)+ 唯一 system prompt。 + * 组装 ReAct agent:固定 7 工具 + 唯一 system prompt。 * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。 * - * @param convId 会话 id(useSkill 落 skill_active 事件需要) + * @param convId 会话 id(useSkill/previewChange 落事件需要) * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记, * 前端历史不显示 */ @@ -84,8 +89,8 @@ public class AgentFactory { Object[] tools = new Object[]{kgQueryTool, interactionTool, new UseSkillTool(skillService, ledger, convId), new ErpReadTool(erp, resolver, identity), - new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), - new FormCollectTool(jdbc, resolver, identity, mapper)}; + new PreviewChangeTool(previews, identity, convId), + new FormCollectTool(render, resolver, identity, mapper)}; return AiServices.builder(ReActAgent.class) .streamingChatModel(streamingModel) @@ -97,9 +102,4 @@ public class AgentFactory { .systemMessageProvider(memoryId -> systemPromptService.prompt()) .build(); } - - /** 供确定性「表单提交」端点直接调 proposeWrite(action=create)(按钮路径,不经 LLM)。 */ - public ProposeWriteTool proposeWriteTool(AgentIdentity identity) { - return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver); - } } diff --git a/src/main/java/com/xly/service/AuditService.java b/src/main/java/com/xly/service/AuditService.java deleted file mode 100644 index 880c755..0000000 --- a/src/main/java/com/xly/service/AuditService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.xly.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -/** - * 业务审计:写操作 / SQL 的不可变留痕(谁、何时、什么动作、目标、结果)。 - * 记账失败绝不阻断主流程(审计是旁路)。 - */ -@Service -public class AuditService { - - private static final Logger log = LoggerFactory.getLogger(AuditService.class); - - private final JdbcTemplate jdbc; - - public AuditService(JdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public void log(String userId, String conversationId, String action, - String target, String detail, boolean ok, String resultMsg) { - try { - jdbc.update( - "INSERT INTO ai_audit_log(tTime,sUserId,sConversationId,sAction,sTarget,sDetail,sResult,sResultMsg) " + - "VALUES(NOW(),?,?,?,?,?,?,?)", - userId, conversationId, action, trunc(target, 200), trunc(detail, 60000), - ok ? "ok" : "fail", trunc(resultMsg, 500)); - } catch (Exception e) { - log.warn("audit write failed (action={}): {}", action, e.getMessage()); - } - } - - private static String trunc(String s, int max) { - if (s == null) { - return null; - } - return s.length() > max ? s.substring(0, max) : s; - } -} diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 9db0981..ba19b8a 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -19,18 +19,19 @@ import java.util.List; import java.util.Map; /** - * ERP 后端(xlyEntry)薄 HTTP 客户端。 - * - *

xlyAi 的读写业务逻辑在 ERP 后端,xlyAi 只是像前端一样发同样的 API 请求。本类负责: + * ERP 后端(xlyEntry)薄 HTTP 客户端——rearch3 收权后**只读**(§3:AI 侧唯一的写 = ai_op_queue, + * 执行/审计全部移交 ERP 侧;原 addUpdateDelBusinessData/updatebInvalid/doExamine/execStaging + * 等写方法已全部退役)。保留: *

*/ @Service @@ -102,7 +103,7 @@ public class ErpClient { } /** - * 拼进 URL 的 id(formId/moduleId/opId)必须是纯 id 形态。这些值可能来自模型输出, + * 拼进 URL 的 id(formId/moduleId)必须是纯 id 形态。这些值可能来自模型输出, * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。 */ private static String safeId(String id) { @@ -197,166 +198,7 @@ public class ErpClient { } } - /** - * 执行一次字段更新(addUpdateDelBusinessData, handleType=update)。会话过期自动重登重试。 - * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 - */ - public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { - return updateForm(null, moduleId, table, billId, field, value); - } - - public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) { - JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken)); - } - return root; - } - - /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ - public JsonNode deleteForm(String moduleId, String table, String billId) { - return deleteForm(null, moduleId, table, billId); - } - - public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) { - JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doDelete(moduleId, table, billId, resolveToken(authToken)); - } - return root; - } - - /** - * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。 - * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。 - */ - public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) { - JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doExamine(moduleId, billId, iFlag, resolveToken(authToken)); - } - return root; - } - - /** - * 作废 / 取消作废(复原)一条记录(ERP {@code /checkModel/updatebInvalid} → 存储过程 {@code Sp_Invalidation})。 - * {@code cancel=false} → 作废(handleType=toVoid);{@code cancel=true} → 取消作废/复原(handleType=cancel)。 - * 业务单据的“删除”应走这里(软作废,可复原),而非物理删。会话过期自动重登重试。 - */ - public JsonNode invalidForm(String authToken, String moduleId, String table, String billId, boolean cancel) { - JsonNode root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doInvalid(moduleId, table, billId, cancel, resolveToken(authToken)); - } - return root; - } - - private JsonNode doInvalid(String moduleId, String table, String billId, boolean cancel, String tok) { - try { - String url = baseUrl + "/checkModel/updatebInvalid?sModelsId=" + safeId(moduleId); - Map body = new LinkedHashMap<>(); - body.put("sId", List.of(billId)); // 后端 sId 期望 List - body.put("sTableName", table); - body.put("handleType", cancel ? "cancel" : "toVoid"); - body.put("sClientType", "PC"); - body.put("sComputeName", "AI-Agent"); - body.put("sIpAddress", "127.0.0.1"); - body.put("sLanguage", "chinese"); - String json = mapper.writeValueAsString(body); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) - .timeout(Duration.ofSeconds(60)) - .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return mapper.readTree(resp.body()); - } catch (Exception e) { - throw new RuntimeException("ERP 作废异常: " + e.getMessage(), e); - } - } - - /** - * 委托 ERP 侧暂存执行器执行一条暂存写操作(架构 §10)。ERP 读共享库 {@code ai_op_queue} 行、以用户身份 - * 执行并回写状态,返回 {@code {status, msg, billId}}。用户 token 必传(执行以用户身份进行)。 - */ - public JsonNode execStaging(String authToken, String opId) { - try { - String url = baseUrl + "/ai/execStaging/" + safeId(opId); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", resolveToken(authToken)) - .timeout(Duration.ofSeconds(120)) - .POST(HttpRequest.BodyPublishers.ofString("{}", StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - JsonNode root = mapper.readTree(resp.body()); - if (root.has("status")) { - return root; - } - // ERP 统一响应包装:真实结果 {status,msg,billId} 在 dataset.rows[0] - JsonNode row = root.path("dataset").path("rows").path(0); - return row.isObject() ? row : root; - } catch (Exception e) { - throw new RuntimeException("ERP 暂存执行异常: " + e.getMessage(), e); - } - } - - private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) { - try { - String url = baseUrl + "/business/doExamine?sModelsId=" + safeId(moduleId); - Map paramsMap = new LinkedHashMap<>(); - paramsMap.put("sFormGuid", moduleId); - paramsMap.put("sGuid", billId); - paramsMap.put("iFlag", iFlag); - paramsMap.put("sSlaveId", ""); - String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap)); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) - .timeout(Duration.ofSeconds(60)) - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return mapper.readTree(resp.body()); - } catch (Exception e) { - throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e); - } - } - - private JsonNode doDelete(String moduleId, String table, String billId, String tok) { - try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); - Map col = new LinkedHashMap<>(); - col.put("handleType", "del"); - col.put("sId", billId); - Map dataItem = new LinkedHashMap<>(); - dataItem.put("sTable", table); - dataItem.put("name", "master"); - dataItem.put("column", List.of(col)); - String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem))); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) - .timeout(Duration.ofSeconds(30)) - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return mapper.readTree(resp.body()); - } catch (Exception e) { - throw new RuntimeException("ERP 删除异常: " + e.getMessage(), e); - } - } - - /** 取一个新主键 uuid(ERP `/getUuid`)。 */ - public String newUuid() { - return newUuid(null); - } - + /** 取一个新主键 uuid(ERP `/getUuid`;为 create 载荷预生成主键,非业务写入)。 */ public String newUuid(String authToken) { try { HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) @@ -373,109 +215,51 @@ public class ErpClient { } /** - * 新增一条记录。走 {@code addUpdateDelBusinessData?sModelsId=}(handleType=add)——与 ERP 前端一致, - * 复用校验/单号/租户;{@code columns} 里已由 ProposeWrite 备好 sId/sFormId/sBillNo 与类型化字段值。 - * 会话过期自动重登重试(仅 dev-login)。 - */ - public JsonNode createForm(String authToken, String moduleId, String table, Map columns) { - JsonNode root = doCreate(moduleId, table, columns, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doCreate(moduleId, table, columns, resolveToken(authToken)); - } - return root; - } - - /** - * 多表联动新增(主-从:报价 = 主表 + 印刷/部件从表 + 多数量)。{@code tables} 每项 = {@code {sTable, name, column(map)}}, - * 本方法给每行补 handleType=add 并一次性 POST addUpdateDelBusinessData,从表靠 sParentId 关联主表。 + * Phase D 接口位:ERP dry-run 校验 {@code /business/checkBusinessData}(复用真实校验链、 + * REQUIRES_NEW 独立事务强制回滚,validate-only)。请求体与 addUpdateDelBusinessData 同形。 + * {@code payloadJson} = 列->值 JSON(create 可含 __tables__ 多表);{@code handleType} = add|update|del。 + * ⚠️ ERP 侧该端点尚未提交上线(他人负责);调用方须由 {@code erp.dry-run.enabled} 开关保护。 */ - public JsonNode createMulti(String authToken, String moduleId, List> tables) { - JsonNode root = doCreateMulti(moduleId, tables, resolveToken(authToken)); - if (root.path("code").asInt() == -2 && canRelogin(authToken)) { - login(); - root = doCreateMulti(moduleId, tables, resolveToken(authToken)); - } - return root; - } - - @SuppressWarnings("unchecked") - private JsonNode doCreateMulti(String moduleId, List> tables, String tok) { + public JsonNode checkBusinessData(String authToken, String moduleId, String table, + String payloadJson, String handleType) { try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); + String url = baseUrl + "/business/checkBusinessData?sModelsId=" + safeId(moduleId); + JsonNode payload = mapper.readTree(payloadJson); List> data = new ArrayList<>(); - for (Map t : tables) { - Map col = new LinkedHashMap<>((Map) t.get("column")); - col.put("handleType", "add"); - Map item = new LinkedHashMap<>(); - item.put("sTable", t.get("sTable")); - item.put("name", t.get("name")); - item.put("column", List.of(col)); - data.add(item); + if (payload.has("__tables__")) { + for (JsonNode t : payload.get("__tables__")) { + @SuppressWarnings("unchecked") + Map col = mapper.convertValue(t.get("column"), Map.class); + col.put("handleType", handleType); + data.add(dataItem(t.path("sTable").asText(""), t.path("name").asText("master"), col)); + } + } else { + @SuppressWarnings("unchecked") + Map col = mapper.convertValue(payload, Map.class); + col.put("handleType", handleType); + data.add(dataItem(table, "master", col)); } Map body = new LinkedHashMap<>(); body.put("sModelsId", moduleId); body.put("data", data); HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) + .header("Authorization", resolveToken(authToken)) .timeout(Duration.ofSeconds(40)) .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) .build(); HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return mapper.readTree(resp.body()); } catch (Exception e) { - throw new RuntimeException("ERP 多表新增异常: " + e.getMessage(), e); - } - } - - private JsonNode doCreate(String moduleId, String table, Map columns, String tok) { - try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); - Map col = new LinkedHashMap<>(columns); - col.put("handleType", "add"); - Map dataItem = new LinkedHashMap<>(); - dataItem.put("sTable", table); - dataItem.put("name", "master"); - dataItem.put("column", List.of(col)); - Map body = new LinkedHashMap<>(); - body.put("sModelsId", moduleId); - body.put("data", List.of(dataItem)); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) - .timeout(Duration.ofSeconds(30)) - .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return mapper.readTree(resp.body()); - } catch (Exception e) { - throw new RuntimeException("ERP 新增异常: " + e.getMessage(), e); + throw new RuntimeException("ERP 校验异常: " + e.getMessage(), e); } } - private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { - try { - String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + safeId(moduleId); - Map col = new LinkedHashMap<>(); - col.put("handleType", "update"); - col.put("sId", billId); - col.put(field, value); - Map dataItem = new LinkedHashMap<>(); - dataItem.put("sTable", table); - dataItem.put("name", "master"); - dataItem.put("column", List.of(col)); - String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem))); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .header("Content-Type", "application/json;charset=UTF-8") - .header("Authorization", tok) - .timeout(Duration.ofSeconds(30)) - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return mapper.readTree(resp.body()); - } catch (Exception e) { - throw new RuntimeException("ERP 更新异常: " + e.getMessage(), e); - } + private Map dataItem(String table, String name, Map col) { + Map item = new LinkedHashMap<>(); + item.put("sTable", table); + item.put("name", name); + item.put("column", List.of(col)); + return item; } } diff --git a/src/main/java/com/xly/service/FormRenderService.java b/src/main/java/com/xly/service/FormRenderService.java new file mode 100644 index 0000000..81d5d17 --- /dev/null +++ b/src/main/java/com/xly/service/FormRenderService.java @@ -0,0 +1,635 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 表单渲染/校验的**共享内核**(rearch3 §1 分层定案):collectForm(收集信息)与 + * previewChange(请求核准)在 LLM 层是两个语义不同的工具,实现层共用这里的 + * 表单骨架生成、记录定位读取、字段字典映射、FK 名称→id、类型强转、系统列拒改与 create 载荷构建 + * ——预览与保存端点用**同一份校验**,杜绝"预览过、保存拒"的漂移。 + * + *

本服务只读不写:唯一的写入口在 {@link OpService}(ai_op_queue)。 + */ +@Service +public class FormRenderService { + + private static final int MAX_FIELDS = 40; + + private final JdbcTemplate jdbc; + private final FormResolverService resolver; + private final ErpClient erp; + private final ObjectMapper mapper; + + public FormRenderService(JdbcTemplate jdbc, FormResolverService resolver, ErpClient erp, ObjectMapper mapper) { + this.jdbc = jdbc; + this.resolver = resolver; + this.erp = erp; + this.mapper = mapper; + } + + // ---------------------------------------------------------------- 表单骨架 + + /** + * 某表单的业务录入字段骨架(label/控件类型/fk 来源/选项/提示)。 + * 主来源 = 字段字典的业务录入字段(含报价策展),字典没有映射时退回 + * {@code gdsconfigformslave} 的可见可写字段(排除系统列)。 + */ + public List> formSkeleton(String table, String formId) { + List> fields = new ArrayList<>(); + List> biz = resolver.businessFields(table, MAX_FIELDS); + Map types = resolver.columnTypes(table); + for (Map b : biz) { + String col = str(b.get("col")); + if (col == null || col.isBlank()) { + continue; + } + Map f = new LinkedHashMap<>(); + f.put("name", col); + f.put("label", firstNonBlank(str(b.get("label")), col)); + f.put("required", false); + Object target = b.get("table"); + if (target != null) { + f.put("target", String.valueOf(target)); // 报价策展:master/slave/note/manyqtys + } + Object fk = b.get("fk"); + Object opts = b.get("options"); + String type = str(b.get("type")); + if (fk != null && !String.valueOf(fk).isBlank()) { + f.put("type", "fkselect"); + f.put("fkTable", String.valueOf(fk)); + } else if (opts instanceof List && !((List) opts).isEmpty()) { + f.put("type", "select"); + f.put("options", opts); + } else if (type != null && !type.isBlank()) { + f.put("type", type); + } else { + f.put("type", inferType(types.get(col))); + } + Object hint = b.get("hint"); + if (hint != null && !String.valueOf(hint).isBlank()) { + f.put("hint", String.valueOf(hint)); + } + fields.add(f); + } + if (!fields.isEmpty() || formId == null) { + return fields; + } + // 兜底:界面元数据的非只读可见字段 + try { + List> cols = jdbc.queryForList( + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " + + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " + + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); + for (Map c : cols) { + String name = str(c.get("sName")); + if (name == null || name.isBlank() || resolver.isSystemColumn(name)) { + continue; + } + Map f = new LinkedHashMap<>(); + f.put("name", name); + f.put("label", firstNonBlank(str(c.get("sChinese")), name)); + f.put("required", truthy(c.get("bNotEmpty"))); + String def = str(c.get("sDefault")); + if (def != null && !def.isBlank()) { + f.put("default", def); + } + List opts = simpleOptions(str(c.get("sChineseDropDown"))); + if (!opts.isEmpty()) { + f.put("type", "select"); + f.put("options", opts); + } else { + f.put("type", "text"); + } + fields.add(f); + } + } catch (Exception ignore) { + } + return fields; + } + + // ---------------------------------------------------------------- 记录定位 + + /** 定位结果:error 非空表示失败(用户可读消息);否则携带主表与唯一记录。 */ + public static final class Located { + public String error; + public String formId; + public String moduleId; + public String table; + public JsonNode rec; + public String billId; + public String recordName; + } + + /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称/单号模糊匹配 → 0/多条/缺主键报错。 */ + public Located locateRecord(AgentIdentity identity, String entityKeyword, String recordKeyword, String verb) { + Located l = new Located(); + Map form = resolver.resolveMasterForm(entityKeyword == null ? null : entityKeyword.trim()); + if (form == null) { + l.error = "找不到「" + entityKeyword + "」对应的可操作主表。"; + return l; + } + l.formId = str(form.get("sFormId")); + l.moduleId = str(form.get("sModuleId")); + l.table = str(form.get("sDataSource")); + if (!identity.canAccessModule(l.moduleId)) { + l.error = "你没有" + verb + "「" + entityKeyword + "」的权限。"; + return l; + } + String nameField = resolver.searchField(l.table, recordKeyword); + JsonNode root; + try { + root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + l.error = "定位记录失败:" + e.getMessage(); + return l; + } + if (root.path("code").asInt(0) < 0) { + l.error = "定位记录失败:" + root.path("msg").asText("未知错误"); + return l; + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + l.error = "没有找到名称含「" + recordKeyword + "」的记录。"; + return l; + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); + } + l.error = "匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"; + return l; + } + l.rec = data.get(0); + l.billId = l.rec.path("sId").asText(null); + if (isBlank(l.billId)) { + l.error = "定位到的记录缺少主键 sId,无法" + verb + "。"; + return l; + } + l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword); + return l; + } + + /** 保存端点用:按主键重读记录(重校验"所见即所写")。 */ + public Located locateById(AgentIdentity identity, String formId, String moduleId, String table, String billId) { + Located l = new Located(); + l.formId = formId; + l.moduleId = moduleId; + l.table = table; + JsonNode root; + try { + root = erp.readForm(identity.token(), formId, moduleId, 1, 2, "sId", billId); + } catch (Exception e) { + l.error = "重读记录失败:" + e.getMessage(); + return l; + } + if (root.path("code").asInt(0) < 0) { + l.error = "重读记录失败:" + root.path("msg").asText("未知错误"); + return l; + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + if (data == null || !data.isArray() || data.isEmpty()) { + l.error = "该记录已不存在(可能被删除)。"; + return l; + } + l.rec = data.get(0); + l.billId = billId; + return l; + } + + // ---------------------------------------------------------------- 字段解析与规范化 + + /** 字段中文名 → {col, fk}(先精确合并模糊,取使用度最高的一列)。找不到返回 null。 */ + public Map resolveColumn(String table, String zh) { + try { + List> r = jdbc.queryForList( + "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " + + "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + + "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1", + table, zh, "%" + zh + "%"); + if (r.isEmpty()) { + return null; + } + Map m = r.get(0); + Object fk = m.get("fk"); + if (fk != null && ("null".equalsIgnoreCase(String.valueOf(fk)) || String.valueOf(fk).isBlank())) { + m.put("fk", null); + } + return m; + } catch (Exception e) { + return null; + } + } + + /** 规范化结果:error 非空 = 值非法(用户可读消息);否则 stored=入库值、shown=展示值。 */ + public static final class Normalized { + public String error; + public Object stored; + public String shown; + } + + /** + * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内),其余按列类型强转。 + * 预览与保存共用同一份逻辑("所见即所写"的前半)。 + */ + public Normalized normalize(String table, String col, String fk, String label, String value, AgentIdentity identity) { + Normalized n = new Normalized(); + if (fk != null && !fk.isBlank()) { + String id = resolver.resolveFk(fk, identity.brandsId(), value); + if (id == null) { + String ent = label == null ? "" : label.replace("名称", ""); + n.error = "「" + value + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"; + return n; + } + n.stored = id; + n.shown = value; + return n; + } + try { + n.stored = resolver.coerce(resolver.columnTypes(table).get(col), value); + } catch (IllegalArgumentException ex) { + n.error = "「" + label + "」" + ex.getMessage() + ",请给一个有效值。"; + return n; + } + if (n.stored == null) { + n.error = "「" + value + "」不是「" + label + "」可接受的值(日期请用 2026-07-29 这种格式)。"; + return n; + } + n.shown = String.valueOf(n.stored); + return n; + } + + /** 外键 id → 名称(预览展示用:记录里存 id,用户要看名称)。解析不到返回原 id。 */ + public String fkDisplayName(String fkTable, String id) { + if (fkTable == null || fkTable.isBlank() || id == null || id.isBlank()) { + return id; + } + try { + String nameField = resolver.resolveNameField(fkTable); + if (nameField == null) { + return id; + } + List> r = jdbc.queryForList( + "SELECT `" + nameField + "` FROM `" + fkTable + "` WHERE sId=? LIMIT 1", id); + if (!r.isEmpty()) { + Object v = r.get(0).values().iterator().next(); + if (v != null && !String.valueOf(v).isBlank()) { + return String.valueOf(v); + } + } + } catch (Exception ignore) { + } + return id; + } + + // ---------------------------------------------------------------- create 构建 + + /** create 载荷构建结果:error 非空 = 校验失败;否则携带入队所需的全部信息。 */ + public static final class CreateBuild { + public String error; + public String formId; + public String moduleId; + public String table; + public String payload; + public String description; + } + + /** + * 新增载荷构建(表单保存按钮的后端):label→列映射、FK 名称→id、类型强转、系统列拒填、 + * NOT-NULL 无默认列补齐、主键/单号生成。报价走多表构建(主表+印刷从表+多数量)。 + * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。 + */ + public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map fields) { + CreateBuild out = new CreateBuild(); + if (isBlank(entityKeyword)) { + out.error = "缺少单据类型。"; + return out; + } + Map form = resolver.resolveMasterForm(entityKeyword.trim()); + if (form == null) { + out.error = "找不到「" + entityKeyword + "」对应的可新增主表。"; + return out; + } + out.formId = str(form.get("sFormId")); + out.moduleId = str(form.get("sModuleId")); + out.table = str(form.get("sDataSource")); + if (!identity.canAccessModule(out.moduleId)) { + out.error = "你没有新增「" + entityKeyword + "」的权限。"; + return out; + } + if ("quoquotationmaster".equalsIgnoreCase(out.table)) { + return buildQuote(identity, entityKeyword, fields, out); + } + + Map types = resolver.columnTypes(out.table); + Map> labelMap = new LinkedHashMap<>(); + for (Map bfm : resolver.businessFields(out.table, MAX_FIELDS)) { + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); + } + Map col = new LinkedHashMap<>(); + List descParts = new ArrayList<>(); + for (Map.Entry e : fields.entrySet()) { + String zh = e.getKey(); + String v = e.getValue(); + if (isBlank(v)) { + continue; + } + Map fm = labelMap.get(normLabel(zh)); + if (fm == null) { + fm = resolveColumn(out.table, zh); + } + if (fm == null) { + continue; // 该实体没有这个字段,忽略 + } + String colName = str(fm.get("col")); + if (resolver.isSystemColumn(colName)) { + continue; // 制单人/单据日期/租户/单号…由 ERP 注入 + } + String fk = fm.get("fk") == null ? null : str(fm.get("fk")); + Normalized n = normalize(out.table, colName, fk, zh, v, identity); + if (n.error != null) { + out.error = n.error + (fk != null ? "(**不要**因此代建" + zh.replace("名称", "") + ")" : ""); + return out; + } + col.put(colName, n.stored); + descParts.add(zh + "=" + n.shown + (n.shown.equals(v) ? "" : "(原话:" + v + ")")); + } + if (descParts.isEmpty()) { + out.error = "请至少提供一个有效字段(如客户名称)。"; + return out; + } + for (String rc : requiredCols(out.table)) { + if (col.containsKey(rc) || resolver.isSystemColumn(rc)) { + continue; + } + col.put(rc, resolver.typeDefault(rc, types.get(rc))); + } + col.put("sId", erp.newUuid(identity.token())); + col.put("sFormId", out.formId); + String billNo = resolver.nextBillNo(out.table, identity.brandsId()); + if (billNo != null) { + col.put("sBillNo", billNo); + } + try { + out.payload = mapper.writeValueAsString(col); + } catch (Exception e) { + out.error = "内部错误:" + e.getMessage(); + return out; + } + out.description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); + return out; + } + + /** + * 报价多表构建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。 + * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。 + * 价格由 ERP【核价】计算,只落主-从明细。 + */ + private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword, + Map fields, CreateBuild out) { + Map masterTypes = resolver.columnTypes(out.table); + Map slaveTypes = resolver.columnTypes("quoquotationslave"); + Map> labelMap = new LinkedHashMap<>(); + for (Map bfm : resolver.businessFields(out.table, MAX_FIELDS)) { + labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); + } + + Map masterCol = new LinkedHashMap<>(); + Map slaveCol = new LinkedHashMap<>(); + List notes = new ArrayList<>(); + List manyQtys = new ArrayList<>(); + List descParts = new ArrayList<>(); + String custId = null; + for (Map.Entry e : fields.entrySet()) { + String zh = e.getKey(); + String v = e.getValue(); + if (isBlank(v)) { + continue; + } + Map fm = labelMap.get(normLabel(zh)); + if (fm == null) { + continue; + } + String colName = str(fm.get("col")); + String tgt = fm.get("table") == null ? "master" : str(fm.get("table")); + String fk = fm.get("fk") == null ? null : str(fm.get("fk")); + if ("note".equals(tgt)) { + notes.add(zh + "=" + v); + descParts.add(zh + "=" + v); + continue; + } + if ("manyqtys".equals(tgt)) { + for (String q : v.split("[,,、\\s]+")) { + String n = q.trim(); + if (n.isEmpty()) { + continue; + } + if (!n.matches("\\d+(\\.\\d+)?")) { + out.error = "多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。"; + return out; + } + manyQtys.add(n); + } + descParts.add(zh + "=" + String.join(",", manyQtys)); + continue; + } + String targetTable = "slave".equals(tgt) ? "quoquotationslave" : out.table; + Map tt = "slave".equals(tgt) ? slaveTypes : masterTypes; + Normalized n; + if (fk != null && !fk.isBlank()) { + n = normalize(out.table, colName, fk, zh, v, identity); + if (n.error != null) { + out.error = n.error + "(请从下拉里选真实存在的记录;**不要**代建,也不要把产品/规格当成客户)"; + return out; + } + if ("sCustomerId".equals(colName)) { + custId = String.valueOf(n.stored); + } + } else { + n = new Normalized(); + try { + n.stored = resolver.coerce(tt.get(colName), v); + } catch (IllegalArgumentException ex) { + out.error = "「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"; + return out; + } + if (n.stored == null) { + out.error = "「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-29 这种格式)。"; + return out; + } + n.shown = String.valueOf(n.stored); + } + if ("slave".equals(tgt)) { + slaveCol.put(colName, n.stored); + } else { + masterCol.put(colName, n.stored); + } + String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(n.stored); + descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); + } + if (descParts.isEmpty()) { + out.error = "请至少填写一个字段(如客户名称/产品名称/数量)。"; + return out; + } + + for (String rc : requiredCols(out.table)) { + if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) { + continue; + } + masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc))); + } + String masterId = erp.newUuid(identity.token()); + masterCol.put("sId", masterId); + masterCol.put("sFormId", out.formId); + String billNo = resolver.nextBillNo(out.table, identity.brandsId()); + if (billNo != null) { + masterCol.put("sBillNo", billNo); + } + + List> tables = new ArrayList<>(); + tables.add(tableItem("quoquotationmaster", "master", masterCol)); + if (!slaveCol.isEmpty() || !notes.isEmpty()) { + if (!notes.isEmpty()) { + String memo = String.join(";", notes); + Object exist = slaveCol.get("sMaterialsMemo"); + slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo)); + } + slaveCol.put("sId", erp.newUuid(identity.token())); + slaveCol.put("sParentId", masterId); + slaveCol.put("sCustomerId", custId == null ? "" : custId); + tables.add(tableItem("quoquotationslave", "slave", slaveCol)); + } + for (String q : manyQtys) { + Map mq = new LinkedHashMap<>(); + mq.put("sId", erp.newUuid(identity.token())); + mq.put("sParentId", masterId); + mq.put("dManyQty", q); + tables.add(tableItem("quoquotationmanyqtys", "slave", mq)); + } + + Map wrap = new LinkedHashMap<>(); + wrap.put("__tables__", tables); + try { + out.payload = mapper.writeValueAsString(wrap); + } catch (Exception e) { + out.error = "内部错误:" + e.getMessage(); + return out; + } + out.description = "新增【报价】:" + String.join(",", descParts) + + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)"; + return out; + } + + private Map tableItem(String sTable, String name, Map column) { + Map m = new LinkedHashMap<>(); + m.put("sTable", sTable); + m.put("name", name); + m.put("column", column); + return m; + } + + /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ + private List requiredCols(String table) { + List out = new ArrayList<>(); + try { + List> rows = jdbc.queryForList( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + + "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); + Set skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); + for (Map r : rows) { + String c = str(r.get("COLUMN_NAME")); + if (c != null && !skip.contains(c)) { + out.add(c); + } + } + } catch (Exception ignore) { + } + return out; + } + + // ---------------------------------------------------------------- 通用 + + /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */ + public static String normLabel(String s) { + if (s == null) { + return ""; + } + return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); + } + + private static String inferType(String dataType) { + if (dataType == null) { + return "text"; + } + String t = dataType.toLowerCase(); + if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { + return "number"; + } + if (t.contains("date") || t.contains("time")) { + return "date"; + } + return "text"; + } + + private static List simpleOptions(String dropdown) { + List out = new ArrayList<>(); + if (dropdown == null || dropdown.isBlank()) { + return out; + } + String d = dropdown.trim(); + if (d.toLowerCase().contains("select ") || d.length() > 200) { + return out; + } + for (String o : d.split("[、,,|]")) { + String t = o.trim(); + if (!t.isEmpty() && out.size() < 30) { + out.add(t); + } + } + return out; + } + + private static boolean truthy(Object o) { + if (o == null) { + return false; + } + if (o instanceof Boolean b) { + return b; + } + if (o instanceof Number nu) { + return nu.intValue() != 0; + } + if (o instanceof byte[] b) { + return b.length > 0 && b[0] != 0; + } + String s = o.toString().trim(); + return s.equals("1") || s.equalsIgnoreCase("true"); + } + + private static String firstNonBlank(String a, String b) { + return (a != null && !a.isBlank()) ? a : b; + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java index 786275b..ab8b199 100644 --- a/src/main/java/com/xly/service/OpService.java +++ b/src/main/java/com/xly/service/OpService.java @@ -1,5 +1,6 @@ package com.xly.service; +import com.xly.agent.AgentIdentity; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -7,80 +8,92 @@ import java.util.List; import java.util.Map; /** - * ai_op_queue 暂存管理 —— 写操作的人在环闭环:ProposeWrite 写 draft,用户确认后 confirm 端点执行。 + * ai_op_queue 队列写入 —— **AI 侧唯一的写入口**(rearch3 §3 写路径收权)。 + * + *

用户在预览卡/表单上点按钮(保存/审核/作废…)→ 确定性端点校验后经本类落一行 + * {@code sStatus='confirmed'}(用户已当面授权)。**xlyAi 工作到此为止**:是否/何时执行、 + * 执行权属、审计留痕全部由 ERP 侧负责(暂存执行器读本表;现状=报价自动执行、其余进待办)。 + * xlyAi 侧只保留 {@link #statusLabel} 只读展示处理进度(流程卡)。 + * + *

列名兼容:ERP 执行器仍读 {@code sUserId} → 新列 {@code sMakePerson}(ERP 惯例)双写, + * ERP 侧切换后可删 sUserId。 */ @Service public class OpService { private final JdbcTemplate jdbc; - private final AuditService audit; - public OpService(JdbcTemplate jdbc, AuditService audit) { + public OpService(JdbcTemplate jdbc) { this.jdbc = jdbc; - this.audit = audit; } - /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ - public String createDraft(String userId, String opType, - String formId, String moduleId, String table, String billId, - String field, String fieldLabel, String oldValue, String newValue, + /** 单字段更新入队(update)。 */ + public String queueUpdate(AgentIdentity who, String convId, String formId, String moduleId, String table, + String billId, String field, String fieldLabel, String oldValue, String newValue, String description) { - String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); - jdbc.update( - "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + - "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + - "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", - sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); - audit.log(userId, null, "propose", table + "#" + billId, description, true, "draft staged (" + sId + ")"); - return sId; + return insert(who, convId, "update", formId, moduleId, table, billId, + field, fieldLabel, oldValue, newValue, null, description); + } + + /** 状态操作入队:invalid(sNewValue=toVoid|cancel)/ examine(sNewValue=1|0)/ delete。 */ + public String queueStateOp(AgentIdentity who, String convId, String opType, String formId, String moduleId, + String table, String billId, String sNewValue, String description) { + return insert(who, convId, opType, formId, moduleId, table, billId, + null, null, null, sNewValue, null, description); + } + + /** 新增入队(create):payload = 列->值 JSON(多表用 __tables__)。 */ + public String queueCreate(AgentIdentity who, String convId, String formId, String moduleId, String table, + String payload, String description) { + return insert(who, convId, "create", formId, moduleId, table, null, + null, null, null, null, payload, description); } - /** 暂存一条 draft(payload 版,用于 create:sPayload=列->值 JSON)。 */ - public String createDraftPayload(String userId, String opType, String formId, String moduleId, - String table, String payload, String description) { + private String insert(AgentIdentity who, String convId, String opType, String formId, String moduleId, + String table, String billId, String field, String fieldLabel, String oldValue, + String newValue, String payload, String description) { String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); jdbc.update( - "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + - "sPayload,sDescription,sStatus,tCreateDate) VALUES(?,?,?,?,?,?,?,?, 'draft', NOW())", - sId, userId, opType, formId, moduleId, table, payload, description); - audit.log(userId, null, "propose", table + " (create)", description, true, "draft staged (" + sId + ")"); + "INSERT INTO ai_op_queue(sId,sUserId,sMakePerson,sBrandsId,sSubsidiaryId,sConversationId,sOpType," + + "sTargetFormId,sTargetModuleId,sTargetTable,sTargetBillId,sField,sFieldLabel," + + "sOldValue,sNewValue,sPayload,sDescription,sStatus,tCreateDate,tConfirmDate) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'confirmed',NOW(),NOW())", + sId, who.userId(), who.userId(), who.brandsId(), who.subsidiaryId(), convId, opType, + formId, moduleId, table, billId, field, fieldLabel, + oldValue, newValue, payload, description); return sId; } - /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ - public void attachConversation(String opId, String convId) { - jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); - } - public Map get(String sId) { List> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); return r.isEmpty() ? null : r.get(0); } - /** 会话里最近一条待确认(draft)操作。 */ - public Map pending(String convId) { - List> r = jdbc.queryForList( - "SELECT * FROM ai_op_queue WHERE sConversationId=? AND sStatus='draft' ORDER BY tCreateDate DESC LIMIT 1", - convId); - return r.isEmpty() ? null : r.get(0); - } - - public void setStatus(String sId, String status, String resultMsg) { - jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?", - status, resultMsg, sId); - } - - /** - * 抢占一条 draft(CAS):只有把 draft 改成 executing 的那个请求返回 true,并发/重复确认直接落空。 - * 没有它,两个并发 confirm 都能通过「读到 draft」的判断,把同一张单执行两次。 - */ - public boolean claim(String sId) { - return jdbc.update("UPDATE ai_op_queue SET sStatus='executing', tConfirmDate=NOW() " + - "WHERE sId=? AND sStatus='draft'", sId) == 1; + /** sStatus → 人类可读进度(流程卡只读展示;不做任何处理动作)。查不到返回 null。 */ + public String statusLabel(String sId) { + try { + List> r = jdbc.queryForList( + "SELECT sStatus, sResultMsg, sErrorMsg FROM ai_op_queue WHERE sId=?", sId); + if (r.isEmpty()) { + return null; + } + String st = String.valueOf(r.get(0).get("sStatus")); + String msg = str(r.get(0).get("sResultMsg")); + String err = str(r.get(0).get("sErrorMsg")); + return switch (st) { + case "confirmed" -> "已提交,等待 ERP 处理"; + case "executing" -> "ERP 处理中"; + case "executed" -> "ERP 已执行成功" + (msg.isBlank() ? "" : ":" + msg); + case "failed" -> "ERP 执行失败" + (err.isBlank() ? (msg.isBlank() ? "" : ":" + msg) : ":" + err); + case "cancelled" -> "已取消"; + default -> st; + }; + } catch (Exception e) { + return null; + } } - /** 执行失败/异常时把 executing 退回 draft 之外的终态由 setStatus 负责;这里仅用于放弃抢占。 */ - public void release(String sId) { - jdbc.update("UPDATE ai_op_queue SET sStatus='draft' WHERE sId=? AND sStatus='executing'", sId); + private static String str(Object o) { + return o == null ? "" : o.toString(); } } diff --git a/src/main/java/com/xly/service/PreviewService.java b/src/main/java/com/xly/service/PreviewService.java new file mode 100644 index 0000000..ab00b6f --- /dev/null +++ b/src/main/java/com/xly/service/PreviewService.java @@ -0,0 +1,692 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.agent.AgentIdentity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 写操作预览/保存闭环(rearch3 §1「所见即所写」): + * + *

    + *
  1. {@link #buildPreview}(previewChange 工具的后端,**只读**):定位记录 → 校验改动 + * (字段字典/FK/类型/系统列/状态合法性)→ 渲染整表预览卡(改动高亮/状态变化高亮)→ + * 解析产物(表/记录/列/规范化值/当前值快照)以 previewId 存 Redis({@value #TTL_MINUTES} 分钟 TTL, + * 服务端绑定,客户端只见 previewId);
  2. + *
  3. {@link #save}(用户点卡上按钮后的确定性端点):按 previewId 取回 → 归属/权限重查 → + * **重读记录重校验**(记录被他人改过/状态已变 → 拒绝保存,请重新预览)→ 写 ai_op_queue + * ({@link OpService},AI 侧唯一写入口)→ 落 queued 事件。**xlyAi 到此为止**,执行由 ERP 侧负责。
  4. + *
+ * + * 状态合法性硬检查(预览与保存两次,防 TOCTOU):审核要求未审核、销审要求已审核、 + * 作废要求未作废、复原要求已作废(依据已核实的存储过程行为:重复审核/重复作废 ERP 不拦但有副作用)。 + * 表没有对应状态列时跳过该项检查。 + */ +@Service +public class PreviewService { + + private static final Logger log = LoggerFactory.getLogger(PreviewService.class); + private static final String KEY_PREFIX = "chat:preview:"; + private static final int TTL_MINUTES = 30; + private static final int STATE_SUMMARY_FIELDS = 6; + + private final FormRenderService render; + private final FormResolverService resolver; + private final OpService ops; + private final LedgerService ledger; + private final ErpClient erp; + private final StringRedisTemplate redis; + private final ObjectMapper mapper; + + /** Phase D 接口位:true 时保存前调 ERP dry-run 校验(/business/checkBusinessData,ERP 侧完工后开启)。 */ + @Value("${erp.dry-run.enabled:false}") + private boolean dryRunEnabled; + + public PreviewService(FormRenderService render, FormResolverService resolver, OpService ops, + LedgerService ledger, ErpClient erp, StringRedisTemplate redis, ObjectMapper mapper) { + this.render = render; + this.resolver = resolver; + this.ops = ops; + this.ledger = ledger; + this.erp = erp; + this.redis = redis; + this.mapper = mapper; + } + + // ---------------------------------------------------------------- 预览 + + /** previewChange 的入口。返回卡片 JSON(type=change_preview)或 {"error":...}。 */ + public String buildPreview(AgentIdentity who, String convId, String action, String entityKeyword, + String recordKeyword, String fieldChinese, String newValue) { + if (isBlank(action)) { + return err("缺少 action。"); + } + String act = normalizeAction(action); + if (act == null) { + return err("未知 action:" + action + "(支持 update/invalid/cancelInvalid/examine/cancelExamine/delete;新增走 collectForm)"); + } + if (isBlank(entityKeyword) || isBlank(recordKeyword)) { + return err("缺少实体类型或记录名称/单号。"); + } + String verb = verbOf(act); + FormRenderService.Located l = render.locateRecord(who, entityKeyword, recordKeyword, verb); + if (l.error != null) { + return err(l.error); + } + return "update".equals(act) + ? buildUpdatePreview(who, convId, entityKeyword, fieldChinese, newValue, l) + : buildStatePreview(who, convId, act, entityKeyword, l); + } + + private String buildUpdatePreview(AgentIdentity who, String convId, String entity, + String fieldChinese, String newValue, FormRenderService.Located l) { + if (isBlank(fieldChinese) || newValue == null) { + return err("update 需要 字段中文名 + 新值。"); + } + Map fm = render.resolveColumn(l.table, fieldChinese.trim()); + if (fm == null) { + return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); + } + String col = String.valueOf(fm.get("col")); + if (resolver.isSystemColumn(col)) { + return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。"); + } + String fk = fm.get("fk") == null ? null : String.valueOf(fm.get("fk")); + FormRenderService.Normalized n = render.normalize(l.table, col, fk, fieldChinese, newValue, who); + if (n.error != null) { + return err(n.error); + } + + String warning = null; + if (readState(l.rec, "bInvalid") == 1) { + warning = "注意:该记录当前是**已作废**状态。"; + } + + // 整表渲染:master 字段的当前真实值 + 被改字段高亮 + List> skeleton = masterFields(l.table, l.formId); + ensureFieldPresent(skeleton, col, fieldChinese, fk, l.table); + List> cardFields = new ArrayList<>(); + Map snapshotRaw = new LinkedHashMap<>(); + Map snapshotShown = new LinkedHashMap<>(); + List> fieldsMeta = new ArrayList<>(); + for (Map f : skeleton) { + String name = String.valueOf(f.get("name")); + String label = String.valueOf(f.get("label")); + String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable")); + String raw = l.rec.path(name).asText(""); + String shown = ffk != null ? render.fkDisplayName(ffk, raw) : raw; + snapshotRaw.put(name, raw); + snapshotShown.put(name, shown); + Map meta = new LinkedHashMap<>(); + meta.put("name", name); + meta.put("label", label); + if (ffk != null) { + meta.put("fk", ffk); + } + fieldsMeta.add(meta); + + Map cf = new LinkedHashMap<>(f); + cf.put("value", shown); + if (name.equals(col)) { + cf.put("changed", true); + cf.put("newValue", n.shown); + } + cardFields.add(cf); + } + + String oldShown = snapshotShown.getOrDefault(col, ""); + String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" + + (oldShown.isBlank() ? "" : ("由「" + oldShown + "」")) + "改为「" + n.shown + "」" + + (n.shown.equals(newValue) ? "" : "(原话:" + newValue + ")"); + + Map draft = new LinkedHashMap<>(); + draft.put("action", "update"); + draft.put("userId", who.userId()); + draft.put("convId", convId); + draft.put("entity", entity); + draft.put("formId", l.formId); + draft.put("moduleId", l.moduleId); + draft.put("table", l.table); + draft.put("billId", l.billId); + draft.put("recordName", l.recordName); + draft.put("changedCol", col); + draft.put("changedLabel", fieldChinese); + draft.put("changedNewShown", n.shown); + draft.put("snapshotRaw", snapshotRaw); + draft.put("snapshotShown", snapshotShown); + draft.put("fieldsMeta", fieldsMeta); + draft.put("description", description); + String previewId = stash(draft); + if (previewId == null) { + return err("预览暂存失败,请稍后重试。"); + } + + Map card = new LinkedHashMap<>(); + card.put("type", "change_preview"); + card.put("previewId", previewId); + card.put("action", "update"); + card.put("entity", entity); + card.put("recordName", l.recordName); + card.put("title", "修改「" + l.recordName + "」(" + entity + ")"); + card.put("summary", description); + card.put("buttonLabel", "保存"); + card.put("editable", true); + card.put("fields", cardFields); + card.put("message", (warning == null ? "" : warning + "\n") + + "请核对整张表单(改动已高亮,可继续修正其他字段),确认后点【保存】。保存前不会写入任何数据。"); + return toJson(card); + } + + private String buildStatePreview(AgentIdentity who, String convId, String act, String entity, + FormRenderService.Located l) { + Map types = resolver.columnTypes(l.table); + int bCheck = readState(l.rec, "bCheck"); + int bInvalid = readState(l.rec, "bInvalid"); + + // 状态合法性硬检查(依据已核实的存储过程行为;无对应列的表跳过) + String illegal = stateIllegalReason(act, types, bCheck, bInvalid, l.recordName); + if (illegal != null) { + return err(illegal); + } + String warning = null; + if ("delete".equals(act) && types.containsKey("bCheck") && bCheck == 1) { + warning = "注意:该单据**已审核**,物理删除有风险且不可恢复,建议改用作废。"; + } + + // 单据摘要(前几个有值的业务字段)+ 状态变化高亮,无字段编辑 + List> summaryFields = new ArrayList<>(); + for (Map f : masterFields(l.table, l.formId)) { + if (summaryFields.size() >= STATE_SUMMARY_FIELDS) { + break; + } + String name = String.valueOf(f.get("name")); + String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable")); + String raw = l.rec.path(name).asText(""); + if (raw.isBlank()) { + continue; + } + Map cf = new LinkedHashMap<>(); + cf.put("name", name); + cf.put("label", f.get("label")); + cf.put("value", ffk != null ? render.fkDisplayName(ffk, raw) : raw); + summaryFields.add(cf); + } + String billNo = l.rec.path("sBillNo").asText(""); + if (!billNo.isBlank()) { + Map bn = new LinkedHashMap<>(); + bn.put("name", "sBillNo"); + bn.put("label", "单号"); + bn.put("value", billNo); + summaryFields.add(0, bn); + } + + Map statusChange = statusChangeOf(act); + String verb = verbOf(act); + String description = verb + "【" + l.recordName + "】(" + entity + ")"; + + Map expected = new LinkedHashMap<>(); + if (types.containsKey("bCheck")) { + expected.put("bCheck", bCheck); + } + if (types.containsKey("bInvalid")) { + expected.put("bInvalid", bInvalid); + } + + Map draft = new LinkedHashMap<>(); + draft.put("action", act); + draft.put("userId", who.userId()); + draft.put("convId", convId); + draft.put("entity", entity); + draft.put("formId", l.formId); + draft.put("moduleId", l.moduleId); + draft.put("table", l.table); + draft.put("billId", l.billId); + draft.put("recordName", l.recordName); + draft.put("expected", expected); + draft.put("description", description); + String previewId = stash(draft); + if (previewId == null) { + return err("预览暂存失败,请稍后重试。"); + } + + Map card = new LinkedHashMap<>(); + card.put("type", "change_preview"); + card.put("previewId", previewId); + card.put("action", act); + card.put("entity", entity); + card.put("recordName", l.recordName); + card.put("title", verb + "「" + l.recordName + "」(" + entity + ")"); + card.put("summary", description); + card.put("buttonLabel", verb); + card.put("editable", false); + card.put("fields", summaryFields); + card.put("statusChange", statusChange); + card.put("message", (warning == null ? "" : warning + "\n") + + "请核对单据信息与状态变化,确认后点【" + verb + "】提交待办。点按钮前不会写入任何数据。"); + return toJson(card); + } + + // ---------------------------------------------------------------- 保存 + + /** + * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → 写 ai_op_queue。 + * editedFields = 卡上(可编辑预览)用户最终确认的 label→值;状态类传 null。 + * 返回 {queued,opIds,description,message} 或 {error}。 + */ + public Map save(AgentIdentity who, String previewId, Map editedFields) { + Map out = new LinkedHashMap<>(); + JsonNode draft = unstash(previewId); + if (draft == null) { + out.put("error", "预览已过期或不存在,请让助手重新生成预览。"); + return out; + } + if (!draft.path("userId").asText("").equals(who.userId())) { + out.put("error", "无权处理该预览。"); + return out; + } + String moduleId = draft.path("moduleId").asText(""); + if (!who.canAccessModule(moduleId)) { + out.put("error", "你没有该单据的操作权限。"); + return out; + } + String action = draft.path("action").asText(""); + String convId = draft.path("convId").asText(""); + FormRenderService.Located cur = render.locateById(who, draft.path("formId").asText(""), + moduleId, draft.path("table").asText(""), draft.path("billId").asText("")); + if (cur.error != null) { + out.put("error", cur.error + " 请重新预览。"); + return out; + } + Map result = "update".equals(action) + ? saveUpdate(who, draft, editedFields, cur, convId) + : saveStateOp(who, draft, cur, convId, action); + if (!result.containsKey("error")) { + discard(previewId); // 一次性:保存成功后预览失效 + } + return result; + } + + @SuppressWarnings("unchecked") + private Map saveUpdate(AgentIdentity who, JsonNode draft, Map editedFields, + FormRenderService.Located cur, String convId) { + Map out = new LinkedHashMap<>(); + String table = draft.path("table").asText(""); + String recordName = draft.path("recordName").asText(""); + JsonNode snapshotRaw = draft.path("snapshotRaw"); + JsonNode snapshotShown = draft.path("snapshotShown"); + String changedCol = draft.path("changedCol").asText(""); + String changedLabel = draft.path("changedLabel").asText(""); + String changedNewShown = draft.path("changedNewShown").asText(""); + + // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段 + Map desired = new LinkedHashMap<>(); + desired.put(changedCol, changedNewShown); + if (editedFields != null && !editedFields.isEmpty()) { + for (JsonNode meta : draft.path("fieldsMeta")) { + String name = meta.path("name").asText(""); + String label = meta.path("label").asText(""); + String v = editedFields.get(label); + if (v != null) { + desired.put(name, v); + } + } + } + + // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半) + record Change(String col, String label, String wasShown, Object stored, String shown) { } + List changes = new ArrayList<>(); + for (JsonNode meta : draft.path("fieldsMeta")) { + String col = meta.path("name").asText(""); + String label = meta.path("label").asText(""); + String fk = meta.path("fk").isMissingNode() ? null : meta.path("fk").asText(null); + String want = desired.get(col); + if (want == null) { + continue; + } + String wasShown = snapshotShown.path(col).asText(""); + if (want.equals(wasShown)) { + continue; // 没改 + } + if (resolver.isSystemColumn(col)) { + out.put("error", "「" + label + "」是系统字段,不能修改。"); + return out; + } + // 所见即所写:预览时的当前值必须仍是现在的当前值(他人已改 → 拒绝) + String rawAtPreview = snapshotRaw.path(col).asText(""); + String rawNow = cur.rec.path(col).asText(""); + if (!rawNow.equals(rawAtPreview)) { + out.put("error", "「" + label + "」在预览之后被其他人修改过(现在是「" + + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。"); + return out; + } + FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, who); + if (n.error != null) { + out.put("error", n.error); + return out; + } + changes.add(new Change(col, label, wasShown, n.stored, n.shown)); + } + if (changes.isEmpty()) { + out.put("error", "没有需要保存的改动。"); + return out; + } + + String dryErr = dryRunUpdate(who, draft, desired, cur); + if (dryErr != null) { + out.put("error", dryErr); + return out; + } + + // 第二遍:逐字段入队(ERP 执行器为单字段 update;一次保存多字段 = 多行待办) + List opIds = new ArrayList<>(); + List descs = new ArrayList<>(); + for (Change c : changes) { + String desc = "将【" + recordName + "】的【" + c.label() + "】" + + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」"; + descs.add(desc); + opIds.add(ops.queueUpdate(who, convId, draft.path("formId").asText(""), + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), + c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc)); + } + String description = String.join(";", descs); + recordQueued(convId, who, opIds, description); + out.put("queued", true); + out.put("opIds", opIds); + out.put("description", description); + out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。"); + return out; + } + + private Map saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur, + String convId, String action) { + Map out = new LinkedHashMap<>(); + String table = draft.path("table").asText(""); + Map types = resolver.columnTypes(table); + int bCheck = readState(cur.rec, "bCheck"); + int bInvalid = readState(cur.rec, "bInvalid"); + // 所见即所写:预览时看到的状态必须没变 + JsonNode expected = draft.path("expected"); + if (expected.has("bCheck") && expected.path("bCheck").asInt() != bCheck) { + out.put("error", "该单据的审核状态在预览之后发生了变化,已拒绝提交,请重新预览。"); + return out; + } + if (expected.has("bInvalid") && expected.path("bInvalid").asInt() != bInvalid) { + out.put("error", "该单据的作废状态在预览之后发生了变化,已拒绝提交,请重新预览。"); + return out; + } + String illegal = stateIllegalReason(action, types, bCheck, bInvalid, draft.path("recordName").asText("")); + if (illegal != null) { + out.put("error", illegal); + return out; + } + String opType; + String sNewValue; + switch (action) { + case "invalid" -> { opType = "invalid"; sNewValue = "toVoid"; } + case "cancelinvalid", "cancelInvalid" -> { opType = "invalid"; sNewValue = "cancel"; } + case "examine" -> { opType = "examine"; sNewValue = "1"; } + case "cancelexamine", "cancelExamine" -> { opType = "examine"; sNewValue = "0"; } + case "delete" -> { opType = "delete"; sNewValue = null; } + default -> { + out.put("error", "未知动作:" + action); + return out; + } + } + String description = draft.path("description").asText(""); + String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""), + draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description); + recordQueued(convId, who, List.of(opId), description); + out.put("queued", true); + out.put("opIds", List.of(opId)); + out.put("description", description); + out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。"); + return out; + } + + // ---------------------------------------------------------------- create(表单保存按钮的后端) + + /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */ + public Map saveCreate(AgentIdentity who, String convId, String entity, Map fields) { + Map out = new LinkedHashMap<>(); + FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields); + if (b.error != null) { + out.put("error", b.error); + return out; + } + String dryErr = dryRunCreate(who, b); + if (dryErr != null) { + out.put("error", dryErr); + return out; + } + String opId = ops.queueCreate(who, convId, b.formId, b.moduleId, b.table, b.payload, b.description); + recordQueued(convId, who, List.of(opId), b.description); + out.put("queued", true); + out.put("opIds", List.of(opId)); + out.put("opId", opId); + out.put("summary", b.description); + out.put("description", b.description); + out.put("message", "已提交待办:" + b.description + "。是否/何时执行由 ERP 处理。"); + return out; + } + + // ---------------------------------------------------------------- Phase D:ERP dry-run 接口位 + + /** ERP 校验 API 完工前恒关(erp.dry-run.enabled=false)。开启后:校验失败返回用户可读错误,网络异常放行(ERP 执行时仍会真校验)。 */ + private String dryRunCreate(AgentIdentity who, FormRenderService.CreateBuild b) { + if (!dryRunEnabled) { + return null; + } + try { + JsonNode r = erp.checkBusinessData(who.token(), b.moduleId, b.table, b.payload, "add"); + if (r != null && r.path("code").asInt(1) != 1) { + return "ERP 校验未通过:" + r.path("msg").asText("数据不合法"); + } + } catch (Exception e) { + log.warn("dry-run create failed (放行,执行时 ERP 仍会校验): {}", e.getMessage()); + } + return null; + } + + private String dryRunUpdate(AgentIdentity who, JsonNode draft, Map desired, + FormRenderService.Located cur) { + if (!dryRunEnabled) { + return null; + } + try { + Map col = new LinkedHashMap<>(); + col.put("sId", draft.path("billId").asText("")); + desired.forEach(col::put); + JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""), + draft.path("table").asText(""), mapper.writeValueAsString(col), "update"); + if (r != null && r.path("code").asInt(1) != 1) { + return "ERP 校验未通过:" + r.path("msg").asText("数据不合法"); + } + } catch (Exception e) { + log.warn("dry-run update failed (放行,执行时 ERP 仍会校验): {}", e.getMessage()); + } + return null; + } + + // ---------------------------------------------------------------- 内部 + + private void recordQueued(String convId, AgentIdentity who, List opIds, String description) { + Map data = new LinkedHashMap<>(); + data.put("opIds", opIds); + data.put("description", description); + ledger.append(convId, "queued", data, who); + } + + /** 状态-动作合法性:非法返回用户可读原因,合法返回 null。无对应状态列的表跳过检查。 */ + private static String stateIllegalReason(String act, Map types, int bCheck, int bInvalid, + String recordName) { + boolean hasCheck = types.containsKey("bCheck"); + boolean hasInvalid = types.containsKey("bInvalid"); + switch (act) { + case "examine": + if (hasCheck && bCheck == 1) { + return "【" + recordName + "】已经是**已审核**状态,无需重复审核(重复审核会覆盖审核人并重跑回写)。"; + } + if (hasInvalid && bInvalid == 1) { + return "【" + recordName + "】是已作废单据,不能审核。"; + } + break; + case "cancelexamine", "cancelExamine": + if (hasCheck && bCheck == 0) { + return "【" + recordName + "】还未审核,无法销审。"; + } + break; + case "invalid": + if (hasInvalid && bInvalid == 1) { + return "【" + recordName + "】已经是**已作废**状态,无需重复作废(重复作废会重跑上下游回写)。"; + } + break; + case "cancelinvalid", "cancelInvalid": + if (hasInvalid && bInvalid == 0) { + return "【" + recordName + "】不是作废状态,无需复原。"; + } + break; + default: + } + return null; + } + + /** 记录的状态位(bCheck/bInvalid):1/true → 1,其余 → 0;列不存在也返回 0(调用方按 types 判断是否采信)。 */ + private static int readState(JsonNode rec, String col) { + JsonNode v = rec == null ? null : rec.path(col); + if (v == null || v.isMissingNode() || v.isNull()) { + return 0; + } + String s = v.asText(""); + return "1".equals(s) || "true".equalsIgnoreCase(s) ? 1 : 0; + } + + private static Map statusChangeOf(String act) { + Map m = new LinkedHashMap<>(); + switch (act) { + case "examine" -> { m.put("label", "审核状态"); m.put("from", "未审核"); m.put("to", "已审核"); } + case "cancelexamine", "cancelExamine" -> { m.put("label", "审核状态"); m.put("from", "已审核"); m.put("to", "未审核"); } + case "invalid" -> { m.put("label", "单据状态"); m.put("from", "正常"); m.put("to", "已作废(可复原)"); } + case "cancelinvalid", "cancelInvalid" -> { m.put("label", "单据状态"); m.put("from", "已作废"); m.put("to", "正常"); } + case "delete" -> { m.put("label", "单据状态"); m.put("from", "存在"); m.put("to", "物理删除(不可恢复)"); } + default -> { } + } + return m; + } + + private static String verbOf(String act) { + return switch (act) { + case "update" -> "保存"; + case "invalid" -> "作废"; + case "cancelinvalid", "cancelInvalid" -> "取消作废"; + case "examine" -> "审核"; + case "cancelexamine", "cancelExamine" -> "反审核"; + case "delete" -> "删除"; + default -> "操作"; + }; + } + + private static String normalizeAction(String action) { + return switch (action.trim().toLowerCase()) { + case "update" -> "update"; + case "invalid" -> "invalid"; + case "cancelinvalid" -> "cancelInvalid"; + case "examine" -> "examine"; + case "cancelexamine" -> "cancelExamine"; + case "delete" -> "delete"; + default -> null; + }; + } + + /** update 预览渲染的字段集合:master 目标的骨架字段(报价策展含从表字段,改字段只支持主表列)。 */ + private List> masterFields(String table, String formId) { + List> out = new ArrayList<>(); + for (Map f : render.formSkeleton(table, formId)) { + Object target = f.get("target"); + if (target == null || "master".equals(String.valueOf(target))) { + out.add(f); + } + } + return out; + } + + /** 被改字段不在骨架里(低使用度列)时补一行,保证卡上一定能看到改动。 */ + private void ensureFieldPresent(List> skeleton, String col, String label, + String fk, String table) { + for (Map f : skeleton) { + if (col.equals(String.valueOf(f.get("name")))) { + return; + } + } + Map f = new LinkedHashMap<>(); + f.put("name", col); + f.put("label", label); + f.put("required", false); + if (fk != null && !fk.isBlank()) { + f.put("type", "fkselect"); + f.put("fkTable", fk); + } else { + f.put("type", "text"); + } + skeleton.add(0, f); + } + + private String stash(Map draft) { + try { + String id = UUID.randomUUID().toString().replace("-", ""); + redis.opsForValue().set(KEY_PREFIX + id, mapper.writeValueAsString(draft), + Duration.ofMinutes(TTL_MINUTES)); + return id; + } catch (Exception e) { + log.warn("preview stash failed: {}", e.getMessage()); + return null; + } + } + + private JsonNode unstash(String previewId) { + if (previewId == null || previewId.isBlank() || !previewId.matches("[A-Za-z0-9]{16,64}")) { + return null; + } + try { + String json = redis.opsForValue().get(KEY_PREFIX + previewId); + return json == null ? null : mapper.readTree(json); + } catch (Exception e) { + return null; + } + } + + private void discard(String previewId) { + try { + redis.delete(KEY_PREFIX + previewId); + } catch (Exception ignore) { + } + } + + private String err(String msg) { + Map m = new LinkedHashMap<>(); + m.put("error", msg); + return toJson(m); + } + + private String toJson(Map m) { + try { + return mapper.writeValueAsString(m); + } catch (Exception e) { + return "{\"error\":\"内部错误\"}"; + } + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/src/main/java/com/xly/tool/FormCollectTool.java b/src/main/java/com/xly/tool/FormCollectTool.java index 29e3dfe..d4669a0 100644 --- a/src/main/java/com/xly/tool/FormCollectTool.java +++ b/src/main/java/com/xly/tool/FormCollectTool.java @@ -2,10 +2,10 @@ package com.xly.tool; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; +import com.xly.service.FormRenderService; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; -import org.springframework.jdbc.core.JdbcTemplate; import java.util.ArrayList; import java.util.HashMap; @@ -17,24 +17,20 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, + * FormCollect 工具(LLM 层的「收集信息」工具)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, * 而不是逐字段追问。 * - *

字段来源优先 {@link FormResolverService#businessFields}(精选映射 + 字段字典,即"真正要填的业务参数"), - * 每个字段带控件类型 {@code fkselect|select|number|date|text};外键字段的下拉选项由前端另调 - * {@code GET /api/agent/form/options} 从来源表实时取。只有该表在字段字典里没有映射时,才退回 - * ERP 界面元数据 {@code gdsconfigformslave} 的可见可写字段。 + *

表单骨架来自 {@link FormRenderService#formSkeleton}(与 previewChange 预览共用同一实现层); + * 本类负责预填:用户已说的值(含尺寸表达的确定性拆分)对齐到表单字段。 * *

工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 - * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回, - * agent 再据此走 {@code proposeWrite(action=create)}(人在环写入)。适合报价这类字段多的新建场景。 + * SSE {@code form_collect} 事件,前端渲染表单;用户填完点【保存】→ 确定性端点 + * {@code /api/agent/form/submit} 校验并写 ai_op_queue(AI 侧唯一写入口),不再经过 LLM。 * *

由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 */ public class FormCollectTool { - private static final int MAX_FIELDS = 40; - // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理) // 标注式:"长和宽50"、"高5cm"、"长:50" private static final Pattern DIM_LABELED = @@ -43,14 +39,14 @@ public class FormCollectTool { private static final Pattern DIM_POS = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); - private final JdbcTemplate jdbc; + private final FormRenderService render; private final FormResolverService resolver; private final AgentIdentity identity; private final ObjectMapper mapper; - public FormCollectTool(JdbcTemplate jdbc, FormResolverService resolver, + public FormCollectTool(FormRenderService render, FormResolverService resolver, AgentIdentity identity, ObjectMapper mapper) { - this.jdbc = jdbc; + this.render = render; this.resolver = resolver; this.identity = identity; this.mapper = mapper; @@ -59,7 +55,7 @@ public class FormCollectTool { @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景" + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填" + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。" - + "用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。") + + "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。") public String collectForm( @P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword, @P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}", @@ -80,76 +76,7 @@ public class FormCollectTool { } String table = String.valueOf(form.get("sDataSource")); - List> fields = new ArrayList<>(); - - // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 - // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 - List> biz = resolver.businessFields(table, MAX_FIELDS); - Map types = resolver.columnTypes(table); - for (Map b : biz) { - String col = str(b.get("col")); - if (col == null || col.isBlank()) { - continue; - } - Map f = new LinkedHashMap<>(); - f.put("name", col); - f.put("label", firstNonBlank(str(b.get("label")), col)); - f.put("required", false); - Object fk = b.get("fk"); - Object opts = b.get("options"); - String type = str(b.get("type")); - if (fk != null && !String.valueOf(fk).isBlank()) { - // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id - f.put("type", "fkselect"); - f.put("fkTable", String.valueOf(fk)); - } else if (opts instanceof List && !((List) opts).isEmpty()) { - f.put("type", "select"); - f.put("options", opts); - } else if (type != null && !type.isBlank()) { - f.put("type", type); - } else { - f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text - } - Object hint = b.get("hint"); - if (hint != null && !String.valueOf(hint).isBlank()) { - f.put("hint", String.valueOf(hint)); - } - fields.add(f); - } - - // 兜底:字段字典没有该表映射时,退回 gdsconfigformslave 的非只读可见字段(排除系统列)。 - if (fields.isEmpty()) { - try { - List> cols = jdbc.queryForList( - "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " + - "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " + - "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); - for (Map c : cols) { - String name = str(c.get("sName")); - if (name == null || name.isBlank() || resolver.isSystemColumn(name)) { - continue; - } - Map f = new LinkedHashMap<>(); - f.put("name", name); - f.put("label", firstNonBlank(str(c.get("sChinese")), name)); - f.put("required", truthy(c.get("bNotEmpty"))); - String def = str(c.get("sDefault")); - if (def != null && !def.isBlank()) { - f.put("default", def); - } - List opts = simpleOptions(str(c.get("sChineseDropDown"))); - if (!opts.isEmpty()) { - f.put("type", "select"); - f.put("options", opts); - } else { - f.put("type", "text"); - } - fields.add(f); - } - } catch (Exception e) { - return err("读取表单结构失败:" + e.getMessage()); - } - } + List> fields = render.formSkeleton(table, formId); if (fields.isEmpty()) { return err("该表单没有可填写的业务字段。"); } @@ -162,70 +89,10 @@ public class FormCollectTool { out.put("moduleId", moduleId); out.put("title", "新建" + entityKeyword.trim()); out.put("fields", fields); - out.put("message", "请在下方表单里填写,填完点【提交】。"); + out.put("message", "请在下方表单里填写,填完点【保存】。"); return toJson(out); } - /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */ - private List simpleOptions(String dropdown) { - List out = new ArrayList<>(); - if (dropdown == null || dropdown.isBlank()) { - return out; - } - String d = dropdown.trim(); - if (d.toLowerCase().contains("select ") || d.length() > 200) { - return out; // SQL 或过长 -> 不当作枚举 - } - for (String o : d.split("[、,,|]")) { - String t = o.trim(); - if (!t.isEmpty() && out.size() < 30) { - out.add(t); - } - } - return out; - } - - /** 按列的 MySQL 类型推断表单控件类型。 */ - private static String inferType(String dataType) { - if (dataType == null) { - return "text"; - } - String t = dataType.toLowerCase(); - if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { - return "number"; - } - if (t.contains("date") || t.contains("time")) { - return "date"; - } - return "text"; - } - - private static boolean truthy(Object o) { - if (o == null) { - return false; - } - if (o instanceof Boolean) { - return (Boolean) o; - } - if (o instanceof Number) { - return ((Number) o).intValue() != 0; - } - if (o instanceof byte[]) { - byte[] b = (byte[]) o; - return b.length > 0 && b[0] != 0; - } - String s = o.toString().trim(); - return s.equals("1") || s.equalsIgnoreCase("true"); - } - - private static String firstNonBlank(String a, String b) { - return (a != null && !a.isBlank()) ? a : b; - } - - private static String str(Object o) { - return o == null ? null : o.toString(); - } - /** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */ private Map parseKnown(String json) { Map m = new HashMap<>(); @@ -248,10 +115,7 @@ public class FormCollectTool { } private static String normLabel(String s) { - if (s == null) { - return ""; - } - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); + return FormRenderService.normLabel(s); } /** diff --git a/src/main/java/com/xly/tool/PreviewChangeTool.java b/src/main/java/com/xly/tool/PreviewChangeTool.java new file mode 100644 index 0000000..175d397 --- /dev/null +++ b/src/main/java/com/xly/tool/PreviewChangeTool.java @@ -0,0 +1,43 @@ +package com.xly.tool; + +import com.xly.agent.AgentIdentity; +import com.xly.service.PreviewService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +/** + * PreviewChange 工具(写路径的模型侧入口,**只读**)。 + * + *

模型解析出「对哪条记录做什么改动」后,本工具生成一张**预览卡**:整表当前值 + 改动/状态变化高亮 + + * ERP 同名操作按钮(保存/审核/反审核/作废/取消作废/删除)。**本工具不写入任何数据**——用户点卡上按钮后, + * 确定性端点(/api/agent/preview/{id}/save)重校验并写 ai_op_queue(AI 侧唯一写入口),执行由 ERP 侧负责。 + * + *

由 {@code AgentFactory} 按请求身份新建:携带 {@link AgentIdentity}(工具内鉴权)与会话 id。 + */ +public class PreviewChangeTool { + + private final PreviewService previews; + private final AgentIdentity identity; + private final String convId; + + public PreviewChangeTool(PreviewService previews, AgentIdentity identity, String convId) { + this.previews = previews; + this.identity = identity; + this.convId = convId; + } + + @Tool("为一次写操作生成**预览卡**(只读:本工具绝不写入数据,也不生成任何单据)。卡片展示记录当前内容、" + + "改动或状态变化高亮,并带对应操作按钮(保存/审核/反审核/作废/取消作废/删除);只有用户点了按钮," + + "系统才会提交待办。action:update=改字段(recordKeyword+fieldChinese+newValue);" + + "invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废;examine=审核;" + + "cancelExamine=销审/反审核;delete=物理删除(业务单据别用)。**新增不用本工具**(新增走 collectForm)。" + + "本工具自行定位主表与记录,无需先 findForms。预览卡出现后你就停下等用户,绝不能说已保存/已完成。") + public String previewChange( + @P("动作:update|invalid|cancelInvalid|examine|cancelExamine|delete") String action, + @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword, + @P("记录名称或单号关键词") String recordKeyword, + @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese, + @P(value = "新值(仅 update,逐字照抄用户原话)", required = false) String newValue) { + return previews.buildPreview(identity, convId, action, entityKeyword, recordKeyword, fieldChinese, newValue); + } +} diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java deleted file mode 100644 index 6700e4e..0000000 --- a/src/main/java/com/xly/tool/ProposeWriteTool.java +++ /dev/null @@ -1,632 +0,0 @@ -package com.xly.tool; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.xly.agent.AgentIdentity; -import com.xly.service.ErpClient; -import com.xly.service.FormResolverService; -import com.xly.service.OpService; -import dev.langchain4j.agent.tool.P; -import dev.langchain4j.agent.tool.Tool; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * ProposeWrite 工具(写操作,人在环)。 - * - *

**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值, - * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 - * (见 OpController),不经过 LLM。 - */ -public class ProposeWriteTool { - - private final ErpClient erp; - private final JdbcTemplate jdbc; - private final OpService ops; - private final ObjectMapper mapper; - private final AgentIdentity identity; - private final FormResolverService resolver; - - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, - AgentIdentity identity, FormResolverService resolver) { - this.erp = erp; - this.jdbc = jdbc; - this.ops = ops; - this.mapper = mapper; - this.identity = identity; - this.resolver = resolver; - } - - @Tool("提议一次写操作(人在环:只生成待确认提议、绝不立即执行;用户点【确认】才写入,你绝不能声称已完成)。" - + "用 action 指定动作:create=新增;update=改某字段;invalid=作废(业务单据要“删除/取消”一律用它,可复原);" - + "cancelInvalid=复原/取消作废;examine=审核;cancelExamine=销审/反审核;delete=物理删除(明细行/极少用,业务单据别用)。" - + "按 action 传参:create 用 fieldsJson;update 用 recordKeyword+fieldChinese+newValue;" - + "invalid/cancelInvalid/examine/cancelExamine/delete 用 recordKeyword 定位单据。本工具自行定位主表,无需先 findForms。") - public String proposeWrite( - @P("动作:create|update|invalid|cancelInvalid|examine|cancelExamine|delete") String action, - @P("实体/单据类型,如 报价 / 客户 / 销售订单") String entityKeyword, - @P(value = "记录名称或单号关键词(create 不需要)", required = false) String recordKeyword, - @P(value = "要改的字段中文名(仅 update)", required = false) String fieldChinese, - @P(value = "新值(仅 update)", required = false) String newValue, - @P(value = "字段 JSON 中文名->值(仅 create)", required = false) String fieldsJson) { - if (isBlank(action)) { - return err("缺少 action。"); - } - switch (action.trim().toLowerCase()) { - case "create": return doCreate(entityKeyword, fieldsJson); - case "update": return doUpdate(entityKeyword, recordKeyword, fieldChinese, newValue); - case "invalid": return doInvalidate(entityKeyword, recordKeyword, false); - case "cancelinvalid": return doInvalidate(entityKeyword, recordKeyword, true); - case "examine": return doExamineOp(entityKeyword, recordKeyword, 1); - case "cancelexamine": return doExamineOp(entityKeyword, recordKeyword, 0); - case "delete": return doDelete(entityKeyword, recordKeyword); - default: - return err("未知 action:" + action + "(支持 create/update/invalid/cancelInvalid/examine/cancelExamine/delete)"); - } - } - - /** 作废(cancel=false)/复原·取消作废(cancel=true)一条记录 → ERP updatebInvalid(Sp_Invalidation)。业务单据的“删除”走这里。 */ - private String doInvalidate(String entityKeyword, String recordKeyword, boolean cancel) { - String verb = cancel ? "复原" : "作废"; - if (isBlank(entityKeyword) || isBlank(recordKeyword)) { - return err("缺少实体类型或记录名称。"); - } - Located l = locateRecord(entityKeyword, recordKeyword, verb); - if (l.error != null) { - return l.error; - } - String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; - // sNewValue 存 handleType:toVoid=作废 / cancel=复原;执行走 ERP updatebInvalid - String opId = ops.createDraft(identity.userId(), "invalid", l.formId, l.moduleId, l.table, l.billId, - null, null, null, cancel ? "cancel" : "toVoid", description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。"); - return toJson(out); - } - - /** 修改某条记录的某个字段。见 {@link #proposeWrite}。 */ - private String doUpdate(String entityKeyword, String recordKeyword, String fieldChinese, String newValue) { - - if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) { - return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); - } - - // 1) 定位主表 + 唯一记录 - Located l = locateRecord(entityKeyword, recordKeyword, "修改"); - if (l.error != null) { - return l.error; - } - - // 2) 字段中文名 -> 技术列名 - String field = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", - l.table, fieldChinese.trim()); - if (field == null) { - field = queryOne( - "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", - l.table, "%" + fieldChinese.trim() + "%"); - } - if (field == null) { - return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); - } - // 系统/审计列(单号、制单人、租户、主键、日期标志…)不允许由对话改写 - if (resolver.isSystemColumn(field)) { - return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。"); - } - String oldValue = l.rec.path(field).asText(""); - - // 3) 值规范化:外键列把名称解析成 id,其余按列类型强转(失败即报错,绝不静默写入错值) - String fkTable = queryOne( - "SELECT MAX(sFkTable) FROM viw_kg_field_dict WHERE sTable=? AND sField=?", l.table, field); - Object stored; - String shown = newValue; - if (fkTable != null && !fkTable.isBlank() && !"null".equalsIgnoreCase(fkTable)) { - String id = resolver.resolveFk(fkTable, identity.brandsId(), newValue); - if (id == null) { - String ent = fieldChinese.replace("名称", ""); - return err("「" + newValue + "」不是系统里已有的" + ent + ",请从已有记录里选一个。"); - } - stored = id; - } else { - try { - stored = resolver.coerce(resolver.columnTypes(l.table).get(field), newValue); - } catch (IllegalArgumentException ex) { - return err("「" + fieldChinese + "」" + ex.getMessage() + ",请给一个有效值。"); - } - if (stored == null) { - return err("「" + newValue + "」不是「" + fieldChinese + "」可接受的值(日期请用 2026-07-28 这种格式)。"); - } - shown = String.valueOf(stored); - } - - // 4) 暂存 draft(不执行)。摘要展示**将要写入的值**,与 payload 同源,避免"看到的与执行的不一致" - String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" - + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + shown + "」" - + (shown.equals(newValue) ? "" : "(原话:" + newValue + ")"); - String opId = ops.createDraft(identity.userId(), "update", l.formId, l.moduleId, l.table, l.billId, - field, fieldChinese, oldValue, String.valueOf(stored), description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。"); - return toJson(out); - } - - /** 物理删除一条记录(罕用;业务单据请用 doInvalidate 作废)。见 {@link #proposeWrite}。 */ - private String doDelete(String entityKeyword, String recordKeyword) { - - if (isBlank(entityKeyword) || isBlank(recordKeyword)) { - return err("缺少实体类型或记录名称。"); - } - Located l = locateRecord(entityKeyword, recordKeyword, "删除"); - if (l.error != null) { - return l.error; - } - String description = "删除【" + l.recordName + "】(" + entityKeyword + ")"; - String opId = ops.createDraft(identity.userId(), "delete", l.formId, l.moduleId, l.table, l.billId, - null, null, l.recordName, null, description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。"); - return toJson(out); - } - - /** 新增一条记录(报价走多表 proposeQuote)。见 {@link #proposeWrite}。 */ - private String doCreate(String entityKeyword, String fieldsJson) { - - if (isBlank(entityKeyword)) { - return err("缺少实体类型。"); - } - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - return err("找不到「" + entityKeyword + "」对应的可新增主表。"); - } - String formId = str(form.get("sFormId")); - String moduleId = str(form.get("sModuleId")); - String table = str(form.get("sDataSource")); - if (!identity.canAccessModule(moduleId)) { - return err("你没有新增「" + entityKeyword + "」的权限。"); - } - - // 报价是跨表主-从单据 → 走多表创建(主表 + 印刷/部件从表 + 多数量) - if ("quoquotationmaster".equalsIgnoreCase(table)) { - return proposeQuote(fieldsJson, formId, moduleId, table); - } - - Map types = resolver.columnTypes(table); - // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 - Map> labelMap = new LinkedHashMap<>(); - for (Map bfm : resolver.businessFields(table, 40)) { - labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); - } - Map col = new LinkedHashMap<>(); - List descParts = new ArrayList<>(); - try { - if (!isBlank(fieldsJson)) { - JsonNode fj = mapper.readTree(fieldsJson.trim()); - Iterator> it = fj.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - String zh = e.getKey(); - String v = e.getValue().asText(""); - if (isBlank(v)) { - continue; - } - Map fm = labelMap.get(normLabel(zh)); - if (fm == null) { - fm = resolveColumn(table, zh); // 回退字段字典 - } - if (fm == null) { - continue; // 该实体没有这个字段,忽略 - } - String colName = str(fm.get("col")); - // 别让 LLM/用户直填系统列(制单人/单据日期/租户/单号…),交给 ERP 与下方系统列处理 - if (resolver.isSystemColumn(colName)) { - continue; - } - String fk = str(fm.get("fk")); - if (fk != null && !fk.isBlank()) { - String id = resolver.resolveFk(fk, identity.brandsId(), v); // 外键:名称 -> id - if (id == null) { - String ent = zh.replace("名称", ""); - return err("「" + v + "」不是系统里已有的" + ent + "。请让用户从下拉里选真实存在的" - + ent + ";**不要**因此新建" + ent + "。"); - } - col.put(colName, id); - descParts.add(zh + "=" + v); - } else { - Object cv; - try { - cv = resolver.coerce(types.get(colName), v); // 按列类型强转 - } catch (IllegalArgumentException ex) { - return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"); - } - if (cv == null) { - return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。"); - } - col.put(colName, cv); - // 摘要只写**真正入库的值**,避免确认卡片与 payload 不一致 - descParts.add(zh + "=" + cv + (String.valueOf(cv).equals(v) ? "" : "(原话:" + v + ")")); - } - } - } - } catch (Exception ex) { - return err("字段解析失败:" + ex.getMessage()); - } - if (descParts.isEmpty()) { - return err("请至少提供一个有效字段(如客户名称)。"); - } - - // 自动补齐 NOT-NULL 无默认列(系统列跳过;其余按类型给默认 0/'') - for (String rc : requiredCols(table)) { - if (col.containsKey(rc) || resolver.isSystemColumn(rc)) { - continue; - } - col.put(rc, resolver.typeDefault(rc, types.get(rc))); - } - // 系统列:主键 + 表单id + 单号(租户/制单人/日期由 ERP 注入,不填) - col.put("sId", erp.newUuid(identity.token())); - col.put("sFormId", formId); - String billNo = resolver.nextBillNo(table, identity.brandsId()); - if (billNo != null) { - col.put("sBillNo", billNo); - } - - String payload; - try { - payload = mapper.writeValueAsString(col); - } catch (Exception e) { - return err("内部错误:" + e.getMessage()); - } - String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); - String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。"); - return toJson(out); - } - - /** 审核(iFlag=1)/反审核·销审(iFlag=0)一条单据 → ERP doExamine。见 {@link #proposeWrite}。 */ - private String doExamineOp(String entityKeyword, String recordKeyword, int iFlag) { - - if (isBlank(entityKeyword) || isBlank(recordKeyword)) { - return err("缺少单据类型或单号/名称。"); - } - String verb = iFlag == 1 ? "审核" : "反审核"; - Located l = locateRecord(entityKeyword, recordKeyword, verb); - if (l.error != null) { - return l.error; - } - String description = verb + "【" + l.recordName + "】(" + entityKeyword + ")"; - // examine:sNewValue 存 iFlag(1=审核,0=反审核/销审);执行走 ERP doExamine(存储过程驱动) - String opId = ops.createDraft(identity.userId(), "examine", l.formId, l.moduleId, l.table, l.billId, - null, null, null, String.valueOf(iFlag), description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的" + verb + ",请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。"); - return toJson(out); - } - - /** - * 报价多表创建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。 - * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。 - * 价格由 ERP【核价】计算,本工具只落主-从明细。生成 __tables__ 结构化 payload,确认端点做多表写入。 - */ - private String proposeQuote(String fieldsJson, String formId, String moduleId, String table) { - Map masterTypes = resolver.columnTypes(table); - Map slaveTypes = resolver.columnTypes("quoquotationslave"); - Map> labelMap = new LinkedHashMap<>(); - for (Map bfm : resolver.businessFields(table, 40)) { - labelMap.put(normLabel(String.valueOf(bfm.get("label"))), bfm); - } - - Map masterCol = new LinkedHashMap<>(); - Map slaveCol = new LinkedHashMap<>(); - List notes = new ArrayList<>(); - List manyQtys = new ArrayList<>(); - List descParts = new ArrayList<>(); - String custId = null; - try { - if (!isBlank(fieldsJson)) { - JsonNode fj = mapper.readTree(fieldsJson.trim()); - Iterator> it = fj.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - String zh = e.getKey(); - String v = e.getValue().asText(""); - if (isBlank(v)) { - continue; - } - Map fm = labelMap.get(normLabel(zh)); - if (fm == null) { - continue; - } - String colName = str(fm.get("col")); - String tgt = fm.get("table") == null ? "master" : str(fm.get("table")); - String fk = str(fm.get("fk")); - if ("note".equals(tgt)) { - notes.add(zh + "=" + v); - descParts.add(zh + "=" + v); - continue; - } - if ("manyqtys".equals(tgt)) { - for (String q : v.split("[,,、\\s]+")) { - String n = q.trim(); - if (n.isEmpty()) { - continue; - } - // 逐个必须是干净数字:以前 replaceAll 会把「三千」吞成空、「1k」变成 1 - if (!n.matches("\\d+(\\.\\d+)?")) { - return err("多数量里的「" + n + "」不是有效数字,请用逗号分隔的纯数字,如 1000,3000,5000。"); - } - manyQtys.add(n); - } - descParts.add(zh + "=" + String.join(",", manyQtys)); - continue; - } - Object value; - if (fk != null && !fk.isBlank()) { - String id = resolver.resolveFk(fk, identity.brandsId(), v); - if (id == null) { - String ent = zh.replace("名称", ""); - return err("「" + v + "」不是系统里已有的" + ent + "。请用 collectForm 让用户从下拉里选真实存在的" - + ent + ";**不要**因此新建" + ent + ",也不要把产品/规格当成客户。"); - } - value = id; - if ("sCustomerId".equals(colName)) { - custId = id; - } - } else { - String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName); - try { - value = resolver.coerce(dt, v); - } catch (IllegalArgumentException ex) { - return err("「" + zh + "」" + ex.getMessage() + ",请给一个有效值。"); - } - if (value == null) { - return err("「" + v + "」不是「" + zh + "」可接受的值(日期请用 2026-07-28 这种格式)。"); - } - } - if ("slave".equals(tgt)) { - slaveCol.put(colName, value); - } else { - masterCol.put(colName, value); - } - // 摘要写真正入库的值(FK 显示用户给的名称,数值显示规范化后的数) - String shown = (fk != null && !fk.isBlank()) ? v : String.valueOf(value); - descParts.add(zh + "=" + shown + (shown.equals(v) ? "" : "(原话:" + v + ")")); - } - } - } catch (Exception ex) { - return err("字段解析失败:" + ex.getMessage()); - } - if (descParts.isEmpty()) { - return err("请至少填写一个字段(如客户名称/产品名称/数量)。"); - } - - // 主表:补必填(非系统) + 主键 + 表单id + 单号 - for (String rc : requiredCols(table)) { - if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) { - continue; - } - masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc))); - } - String masterId = erp.newUuid(identity.token()); - masterCol.put("sId", masterId); - masterCol.put("sFormId", formId); - String billNo = resolver.nextBillNo(table, identity.brandsId()); - if (billNo != null) { - masterCol.put("sBillNo", billNo); - } - - List> tables = new ArrayList<>(); - tables.add(tableItem("quoquotationmaster", "master", masterCol)); - - // 印刷/部件从表:有从表字段或印刷备注就建一行(sCustomerId 从表 NOT-NULL) - if (!slaveCol.isEmpty() || !notes.isEmpty()) { - if (!notes.isEmpty()) { - String memo = String.join(";", notes); - Object exist = slaveCol.get("sMaterialsMemo"); - slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo)); - } - slaveCol.put("sId", erp.newUuid(identity.token())); - slaveCol.put("sParentId", masterId); - slaveCol.put("sCustomerId", custId == null ? "" : custId); - tables.add(tableItem("quoquotationslave", "slave", slaveCol)); - } - // 多数量报价:每个数量一行 - for (String q : manyQtys) { - Map mq = new LinkedHashMap<>(); - mq.put("sId", erp.newUuid(identity.token())); - mq.put("sParentId", masterId); - mq.put("dManyQty", q); - tables.add(tableItem("quoquotationmanyqtys", "slave", mq)); - } - - Map wrap = new LinkedHashMap<>(); - wrap.put("__tables__", tables); - String payload; - try { - payload = mapper.writeValueAsString(wrap); - } catch (Exception e) { - return err("内部错误:" + e.getMessage()); - } - String description = "新增【报价】:" + String.join(",", descParts) - + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)"; - String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description); - - Map out = new LinkedHashMap<>(); - out.put("opId", opId); - out.put("summary", description); - out.put("message", "已为你生成一条待确认的报价新增(主表+印刷明细+多数量),请点【确认】执行、或【取消】。价格在 ERP 里点【核价】计算。"); - return toJson(out); - } - - private Map tableItem(String sTable, String name, Map column) { - Map m = new LinkedHashMap<>(); - m.put("sTable", sTable); - m.put("name", name); - m.put("column", column); - return m; - } - - /** 中文名 -> {col, fk}(字段字典,先精确合并模糊,取使用度最高的一列)。 */ - private Map resolveColumn(String table, String zh) { - try { - List> r = jdbc.queryForList( - "SELECT sField col, MAX(sFkTable) fk FROM viw_kg_field_dict " + - "WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " + - "GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 1", - table, zh, "%" + zh + "%"); - return r.isEmpty() ? null : r.get(0); - } catch (Exception e) { - return null; - } - } - - /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ - private List requiredCols(String table) { - List out = new ArrayList<>(); - try { - List> rows = jdbc.queryForList( - "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " + - "AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table); - Set skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson"); - for (Map r : rows) { - String c = str(r.get("COLUMN_NAME")); - if (c != null && !skip.contains(c)) { - out.add(c); - } - } - } catch (Exception ignore) { - } - return out; - } - - /** 定位实体的可写主表——统一走 FormResolverService(与 collectForm 同源,含从属/参数表排除)。 */ - private Map resolveForm(String entityKeyword) { - return resolver.resolveMasterForm(entityKeyword); - } - - /** {@link #locateRecord} 的结果:error 非空表示失败(已是 err() JSON);否则携带定位到的主表与唯一记录。 */ - private static final class Located { - String error; - String formId; - String moduleId; - String table; - JsonNode rec; - String billId; - String recordName; - } - - /** 定位主表 + 唯一记录(update/invalid/examine/delete 共用):解析主表 → 权限 → 名称字段模糊匹配 → 0 条/多条/缺主键报错。 */ - private Located locateRecord(String entityKeyword, String recordKeyword, String verb) { - Located l = new Located(); - Map form = resolveForm(entityKeyword.trim()); - if (form == null) { - l.error = err("找不到「" + entityKeyword + "」对应的可操作主表。"); - return l; - } - l.formId = str(form.get("sFormId")); - l.moduleId = str(form.get("sModuleId")); - l.table = str(form.get("sDataSource")); - if (!identity.canAccessModule(l.moduleId)) { - l.error = err("你没有" + verb + "「" + entityKeyword + "」的权限。"); - return l; - } - String nameField = resolver.searchField(l.table, recordKeyword); - JsonNode root; - try { - root = erp.readForm(identity.token(), l.formId, l.moduleId, 1, 5, nameField, recordKeyword.trim()); - } catch (Exception e) { - l.error = err("定位记录失败:" + e.getMessage()); - return l; - } - if (root.path("code").asInt(0) < 0) { - l.error = err("定位记录失败:" + root.path("msg").asText("未知错误")); - return l; - } - JsonNode rows = root.path("dataset").path("rows"); - JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; - int n = (data != null && data.isArray()) ? data.size() : 0; - if (n == 0) { - l.error = err("没有找到名称含「" + recordKeyword + "」的记录。"); - return l; - } - if (n > 1) { - StringBuilder names = new StringBuilder(); - for (int i = 0; i < data.size() && i < 5; i++) { - if (i > 0) names.append("、"); - names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); - } - l.error = err("匹配到多条记录(" + names + "),请提供更精确的名称,只" + verb + "其中一条。"); - return l; - } - l.rec = data.get(0); - l.billId = l.rec.path("sId").asText(null); - if (isBlank(l.billId)) { - l.error = err("定位到的记录缺少主键 sId,无法" + verb + "。"); - return l; - } - l.recordName = nameField == null ? recordKeyword : l.rec.path(nameField).asText(recordKeyword); - return l; - } - - private static String str(Object o) { - return o == null ? null : o.toString(); - } - - private String queryOne(String sql, Object... args) { - try { - List> r = jdbc.queryForList(sql, args); - if (!r.isEmpty()) { - Object v = r.get(0).values().iterator().next(); - return v == null ? null : v.toString(); - } - } catch (Exception ignore) { - } - return null; - } - - private static boolean isBlank(String s) { - return s == null || s.isBlank(); - } - - /** 归一化标签用于匹配:去掉括号提示与空白("多数量(逗号分隔)" ⇄ "多数量")。 */ - private static String normLabel(String s) { - if (s == null) { - return ""; - } - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); - } - - private String err(String msg) { - Map m = new LinkedHashMap<>(); - m.put("error", msg); - return toJson(m); - } - - private String toJson(Map m) { - try { - return mapper.writeValueAsString(m); - } catch (Exception e) { - return "{\"error\":\"内部错误\"}"; - } - } -} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 7c77840..db6f26b 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -8,7 +8,7 @@ import com.xly.config.AgentFactory; import com.xly.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.LedgerService; -import com.xly.service.OpService; +import com.xly.service.PreviewService; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.tool.ToolExecution; import org.slf4j.Logger; @@ -35,41 +35,43 @@ import java.util.regex.Pattern; * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * *

编排:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent - * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。 - * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。 - * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*} - * 确定性端点、身份内省 fail-closed、工具内鉴权。 + * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/previewChange/askUser, + * **全部只读/只渲染**)。流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」 + * 卡上跨轮续办)。写路径:previewChange/collectForm 只出卡片,用户点卡上按钮 → 确定性端点 + * (/api/agent/preview/{id}/save、/api/agent/form/submit)校验后写 ai_op_queue(AI 侧唯一写入口), + * 执行由 ERP 侧负责。安全不变量在工具与端点内:身份内省 fail-closed、工具内鉴权、token 不进 prompt。 * *

反编造护栏(无条件常开):零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件, - * 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。 + * 前端不显示),复发则标注未核实;声称「已保存/已提交/已完成」却没出过预览卡/表单 → 附纠正提示。 * - *

帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 - * {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。 + *

帧格式:{@code {"type":"token|reset|done|error"}}、预览卡 {@code change_preview}、澄清 + * {@code question}、表单收集 {@code form_collect}。保存与表单提交走确定性端点,不经过 LLM。 */ @RestController @RequestMapping("/api/agent") public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); - private static final Set WRITE_TOOLS = Set.of("proposeWrite"); - /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */ - private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)"); + private static final Set CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm"); + /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */ + private static final Pattern WRITE_CLAIM = + Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核|保存|录入)"); private final AgentFactory agentFactory; private final AuthzService authz; private final ObjectMapper mapper; private final ConversationService conversations; - private final OpService ops; + private final PreviewService previews; private final LedgerService ledger; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, - ConversationService conversations, OpService ops, LedgerService ledger) { + ConversationService conversations, PreviewService previews, LedgerService ledger) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; - this.ops = ops; + this.previews = previews; this.ledger = ledger; } @@ -117,8 +119,9 @@ public class AgentChatController { } /** - * 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create), - * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。 + * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue + * (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。 + * 同步返回 {queued,opId,summary,message} 或 {error}。 */ @PostMapping("/form/submit") public Map formSubmit(@RequestBody FormSubmitReq req, @@ -145,43 +148,26 @@ public class AgentChatController { } }); conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单"); - ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString())); + ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity); - String fieldsJson; try { - fieldsJson = mapper.writeValueAsString(fields); - } catch (Exception e) { - out.put("error", "字段序列化失败:" + e.getMessage()); - return out; - } - String result = agentFactory.proposeWriteTool(identity) - .proposeWrite("create", entity, null, null, null, fieldsJson); - try { - JsonNode r = mapper.readTree(result); - String opId = r.path("opId").asText(null); - if (opId != null && !opId.isBlank()) { - ops.attachConversation(opId, convId); - String summary = r.path("summary").asText(""); - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); - out.put("opId", opId); - out.put("summary", summary); - out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。")); - } else { - String err = r.path("error").asText("无法完成该新增。"); - ledger.append(convId, "assistant", Map.of("text", err)); - out.put("error", err); + Map r = previews.saveCreate(identity, convId, entity, fields); + if (r.containsKey("error")) { + ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity); } + return r; } catch (Exception e) { + log.warn("form submit failed (conv={})", convId, e); out.put("error", "服务异常:" + e.getMessage()); + return out; } - return out; } /** * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对 * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术 - * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没 - * proposeWrite → 附纠正提示。 + * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮 + * 没出过预览卡/表单 → 附纠正提示。 */ private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, String text, boolean allowRetry) { @@ -189,12 +175,12 @@ public class AgentChatController { // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示 ReActAgent agent = agentFactory.build(identity, convId, !allowRetry); AtomicInteger toolCalls = new AtomicInteger(); - AtomicBoolean proposed = new AtomicBoolean(false); + AtomicBoolean carded = new AtomicBoolean(false); TokenStream ts = agent.chat(convId, text); ts.onPartialResponse(token -> send(emitter, "token", token)) .onToolExecuted(te -> { toolCalls.incrementAndGet(); - handleToolExecuted(emitter, convId, te, proposed); + handleToolExecuted(emitter, te, carded); }) .onCompleteResponse(resp -> { String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null @@ -210,8 +196,8 @@ public class AgentChatController { } send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); } - if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) { - send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); + if (!carded.get() && WRITE_CLAIM.matcher(answer).find()) { + send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成预览卡/表单(没有出现卡片即没有任何写入),请重新描述一次您要做的操作。"); } // 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写 send(emitter, "done", ""); @@ -248,33 +234,23 @@ public class AgentChatController { } /** - * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。 - * 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。 + * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件 + * (change_preview / question / form_collect)。落账由 EventLogChatMemory 统一完成 + * (tool_call/tool_result 事件),这里只做 SSE 推送。 */ - private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) { + private void handleToolExecuted(SseEmitter emitter, ToolExecution te, AtomicBoolean carded) { send(emitter, "reset", ""); try { String toolName = te.request() == null ? "" : te.request().name(); - if (te.result() == null) { + if (te.result() == null || !CARD_TOOLS.contains(toolName)) { return; } - if (WRITE_TOOLS.contains(toolName)) { - JsonNode r = mapper.readTree(te.result()); - String opId = r.path("opId").asText(null); - if (opId != null && !opId.isBlank()) { - ops.attachConversation(opId, convId); - Map card = new LinkedHashMap<>(); - card.put("type", "write_proposal"); - card.put("opId", opId); - card.put("summary", r.path("summary").asText("")); - sendEvent(emitter, card); - proposed.set(true); - } - } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { - JsonNode r = mapper.readTree(te.result()); - String type = r.path("type").asText(""); - if ("question".equals(type) || "form_collect".equals(type)) { - sendEvent(emitter, mapper.convertValue(r, Map.class)); + JsonNode r = mapper.readTree(te.result()); + String type = r.path("type").asText(""); + if ("change_preview".equals(type) || "question".equals(type) || "form_collect".equals(type)) { + sendEvent(emitter, mapper.convertValue(r, Map.class)); + if ("change_preview".equals(type) || "form_collect".equals(type)) { + carded.set(true); } } } catch (Exception e) { diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java deleted file mode 100644 index c251b6e..0000000 --- a/src/main/java/com/xly/web/OpController.java +++ /dev/null @@ -1,283 +0,0 @@ -package com.xly.web; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.xly.agent.AgentIdentity; -import com.xly.service.AuditService; -import com.xly.service.AuthzService; -import com.xly.service.ConversationService; -import com.xly.service.ErpClient; -import com.xly.service.FormResolverService; -import com.xly.service.LedgerService; -import com.xly.service.OpService; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * 写操作确认端点(**确定性、不经过 LLM**)—— 人在环写入闭环的执行侧。 - * - *

用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。 - * 覆盖全部 op 类型:create(含报价这类多表主-从)、update、delete、invalid/cancelInvalid、 - * examine/cancelExamine。执行完回写 {@code ai_op_queue} 状态 + 审计。 - * - *

两条执行路径由 {@code erp.exec-staging.enabled} 切换:直连 ERP 通用写接口(本地默认), - * 或委托 ERP 侧暂存执行器 {@code /ai/execStaging}(生产路径,事务化、以用户身份执行)。 - * - *

当前**恒同步**:确认即执行并同步返回终态。架构 §10 设计的「auto 流程异步 + 卡片三态 + SSE 终态推送」 - * 尚未实现(见 docs/agent-architecture.md §18)。 - */ -@RestController -@RequestMapping("/api/agent/op") -public class OpController { - - private static final Logger log = LoggerFactory.getLogger(OpController.class); - - private final OpService ops; - private final ErpClient erp; - private final AuditService audit; - private final ObjectMapper mapper; - private final LedgerService ledger; - private final AuthzService authz; - private final ConversationService conversations; - private final FormResolverService resolver; - - /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ - @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") - private boolean execStagingEnabled; - - public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, - LedgerService ledger, - AuthzService authz, ConversationService conversations, - FormResolverService resolver) { - this.ops = ops; - this.erp = erp; - this.audit = audit; - this.mapper = mapper; - this.ledger = ledger; - this.authz = authz; - this.conversations = conversations; - this.resolver = resolver; - } - - /** 确认/取消的结果落事件日志(rightPush 原子,流式输出中途点确认也不覆盖对话事件)——LLM 下轮经投影即知这单已执行/失败/取消。 */ - private void recordOutcome(String conv, String opId, String status, String msg, String description) { - if (conv == null || conv.isBlank() || "null".equals(conv)) { - return; - } - try { - ledger.append(conv, "cancelled".equals(status) ? "cancel" : "confirm", Map.of( - "opId", opId == null ? "" : opId, - "status", status, - "msg", msg == null ? "" : msg, - "description", description == null ? "" : description)); - } catch (Exception e) { - log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); - } - } - - /** - * 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 - * 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。 - */ - @GetMapping("/pending") - public Map pending(@RequestParam("conversationId") String conversationId, - @RequestHeader(value = "Authorization", required = false) String authToken) { - String uid = requireLogin(authToken).userId(); - if (!conversations.owns(uid, conversationId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话"); - } - Map op = ops.pending(conversationId); - if (op == null) { - return Map.of(); - } - Map out = new LinkedHashMap<>(); - out.put("opId", op.get("sId")); - out.put("summary", op.get("sDescription")); - out.put("status", op.get("sStatus")); - return out; - } - - /** - * 确认执行:调 ERP 执行暂存的写操作,回写状态。 - *

{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份 - * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。 - */ - @PostMapping("/{id}/confirm") - public Map confirm(@PathVariable("id") String id, - @RequestHeader(value = "Authorization", required = false) String authToken) { - AgentIdentity me = requireLogin(authToken); - Map op = ops.get(id); - if (op == null) { - return result("failed", "找不到该操作", null); - } - requireOwner(me, op); - if (!"draft".equals(String.valueOf(op.get("sStatus")))) { - return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); - } - // CAS 抢占:并发/重复确认只有一个能真正执行,杜绝同一张单被执行两次 - if (!ops.claim(id)) { - Map cur = ops.get(id); - String st = cur == null ? "unknown" : String.valueOf(cur.get("sStatus")); - return result(st, "该操作正在处理或已处理过(" + st + ")", op); - } - String uid = str(op.get("sUserId")); - String conv = str(op.get("sConversationId")); - String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")); - String detail = str(op.get("sDescription")); - - // 生产路径:委托 ERP 侧暂存执行器(它以用户身份执行、事务化并自行回写 ai_op_queue 状态)。 - if (execStagingEnabled) { - return confirmViaExecutor(id, op, authToken, uid, conv, target, detail); - } - try { - String opType = str(op.get("sOpType")); - JsonNode r; - if ("create".equals(opType)) { - JsonNode payload = mapper.readTree(str(op.get("sPayload"))); - if (payload.has("__tables__")) { // 报价等多表主-从创建 - @SuppressWarnings("unchecked") - List> tables = mapper.convertValue(payload.get("__tables__"), List.class); - if (!tables.isEmpty()) { - @SuppressWarnings("unchecked") - Map masterCol = (Map) tables.get(0).get("column"); - refreshBillNo(masterCol, str(op.get("sTargetTable")), me.brandsId()); - } - r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables); - } else { - @SuppressWarnings("unchecked") - Map columns = mapper.convertValue(payload, Map.class); - refreshBillNo(columns, str(op.get("sTargetTable")), me.brandsId()); - r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns); - } - } else if ("delete".equals(opType)) { - r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); - } else if ("invalid".equals(opType)) { - boolean cancel = "cancel".equals(str(op.get("sNewValue"))); // cancel=复原/取消作废,toVoid=作废 - r = erp.invalidForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), cancel); - } else if ("examine".equals(opType)) { - int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核 - r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag); - } else { - r = erp.updateForm(authToken, - str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), - str(op.get("sField")), str(op.get("sNewValue"))); - } - int code = r.path("code").asInt(0); - if (code == 1) { - ops.setStatus(id, "executed", "操作成功"); - audit.log(uid, conv, "confirm", target, detail, true, "executed"); - recordOutcome(conv, id, "executed", null, detail); - return result("executed", "已执行:" + op.get("sDescription"), op); - } - String msg = r.path("msg").asText("执行失败"); - ops.setStatus(id, "failed", msg); - audit.log(uid, conv, "confirm", target, detail, false, msg); - recordOutcome(conv, id, "failed", msg, detail); - return result("failed", msg, op); - } catch (Exception e) { - log.warn("confirm op {} failed", id, e); - ops.setStatus(id, "failed", e.getMessage()); - audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); - recordOutcome(conv, id, "failed", e.getMessage(), detail); - return result("failed", "执行异常:" + e.getMessage(), op); - } - } - - /** 委托 ERP 侧暂存执行器执行(§10);执行器自行回写 ai_op_queue 状态,这里只映射结果 + 审计。 */ - private Map confirmViaExecutor(String id, Map op, String authToken, - String uid, String conv, String target, String detail) { - try { - JsonNode r = erp.execStaging(authToken, id); - String st = r.path("status").asText("failed"); - String msg = r.path("msg").asText(""); - boolean ok = "executed".equals(st); - audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg))); - recordOutcome(conv, id, ok ? "executed" : "failed", msg, detail); - return result(ok ? "executed" : "failed", - ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op); - } catch (Exception e) { - log.warn("execStaging confirm op {} failed", id, e); - audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); - recordOutcome(conv, id, "failed", e.getMessage(), detail); - return result("failed", "执行异常:" + e.getMessage(), op); - } - } - - /** 取消(需登录且必须是本人的操作)。 */ - @PostMapping("/{id}/cancel") - public Map cancel(@PathVariable("id") String id, - @RequestHeader(value = "Authorization", required = false) String authToken) { - AgentIdentity me = requireLogin(authToken); - Map op = ops.get(id); - if (op != null) { - requireOwner(me, op); - } - if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { - ops.setStatus(id, "cancelled", "用户取消"); - audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", - str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), - str(op.get("sDescription")), true, "cancelled"); - recordOutcome(str(op.get("sConversationId")), id, "cancelled", null, str(op.get("sDescription"))); - } - return result("cancelled", "已取消", op); - } - - /** - * 单号在 propose 时算的是当时的 MAX+1;期间别人建过单就会撞号,所以**执行前重新生成**。 - * 拿不到新号时保留旧号(由 ERP 侧唯一约束兜底),不因此中断执行。 - */ - private void refreshBillNo(Map columns, String table, String brandsId) { - if (columns == null || !columns.containsKey("sBillNo")) { - return; - } - String fresh = resolver.nextBillNo(table, brandsId); - if (fresh != null && !fresh.isBlank()) { - columns.put("sBillNo", fresh); - } - } - - /** 需登录(token 服务端内省);未登录/过期 → 401。 */ - private AgentIdentity requireLogin(String authToken) { - AgentIdentity id = authz.resolveIdentity(authToken); - if (id == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); - } - return id; - } - - /** 只有提议的发起人本人能确认/取消——否则任何人拿到 opId 就能替别人写库。 */ - private void requireOwner(AgentIdentity me, Map op) { - String owner = str(op.get("sUserId")); - if (owner == null || !owner.equals(me.userId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权处理该操作"); - } - } - - private Map result(String status, String msg, Map op) { - Map m = new LinkedHashMap<>(); - m.put("status", status); - m.put("msg", msg); - if (op != null) { - m.put("opId", op.get("sId")); - m.put("description", op.get("sDescription")); - } - return m; - } - - private static String str(Object o) { - return o == null ? null : o.toString(); - } -} diff --git a/src/main/java/com/xly/web/PreviewController.java b/src/main/java/com/xly/web/PreviewController.java new file mode 100644 index 0000000..b32cf91 --- /dev/null +++ b/src/main/java/com/xly/web/PreviewController.java @@ -0,0 +1,54 @@ +package com.xly.web; + +import com.xly.agent.AgentIdentity; +import com.xly.service.AuthzService; +import com.xly.service.PreviewService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +/** + * 预览卡按钮端点(**确定性、不经过 LLM**)—— 用户在预览卡上点【保存/审核/作废/…】后调用。 + * + *

{@link PreviewService#save} 按 previewId 取回服务端暂存的解析产物,**重读记录重校验** + * (所见即所写:记录被他人改过/状态已变 → 拒绝),通过后写一行 ai_op_queue(AI 侧唯一写入口)。 + * **xlyAi 到此为止**:是否/何时执行由 ERP 侧负责。 + * + *

安全:需登录(token 服务端内省 fail-closed);预览归属本人(previewId 服务端绑定 userId); + * 写队列前重查表单权限——三层都在 PreviewService.save 内完成。 + */ +@RestController +@RequestMapping("/api/agent/preview") +public class PreviewController { + + private final PreviewService previews; + private final AuthzService authz; + + public PreviewController(PreviewService previews, AuthzService authz) { + this.previews = previews; + this.authz = authz; + } + + public static class SaveReq { + /** 可编辑预览(update)上用户最终确认的 字段中文名->值;状态类操作可空。 */ + public Map fields; + } + + @PostMapping("/{id}/save") + public Map save(@PathVariable("id") String id, + @RequestBody(required = false) SaveReq req, + @RequestHeader(value = "Authorization", required = false) String auth) { + AgentIdentity me = authz.resolveIdentity(auth); + if (me == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); + } + return previews.save(me, id, req == null ? null : req.fields); + } +} diff --git a/src/main/resources/application-saaslocal.yml b/src/main/resources/application-saaslocal.yml index 678ff99..5887571 100644 --- a/src/main/resources/application-saaslocal.yml +++ b/src/main/resources/application-saaslocal.yml @@ -33,10 +33,8 @@ erp: subsidiary: "1111111111" username: admin password: "666666" - # 写入确认执行路径(架构 §10): - # false = xlyAi 直连 ERP 通用写接口执行(本地默认,已测)。 - # true = 委托 ERP 侧暂存执行器 /ai/execStaging(生产路径,以用户 token 身份执行、事务化、支持审核/预填/待办)。 - exec-staging: + # Phase D 接口位:ERP dry-run 校验(/business/checkBusinessData)。ERP 侧完工前保持 false。 + dry-run: enabled: false # LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启: diff --git a/src/main/resources/prompts/system.txt b/src/main/resources/prompts/system.txt index 22d2198..1153235 100644 --- a/src/main/resources/prompts/system.txt +++ b/src/main/resources/prompts/system.txt @@ -7,19 +7,19 @@ 【技能清单(业务流程的权威步骤,用 useSkill 载入全文)】 {SKILL_INDEX}凡要 新建/报价/问价/修改/作废/审核 等会改动数据的业务,或不确定流程怎么走:**先 useSkill 载入对应技能**,再严格按技能步骤执行。简单查询可以直接查。 -【可用工具(只有这些)】 +【可用工具(只有这些,全部只读/只渲染——你没有任何直接写入的能力)】 - useSkill(skillName):载入上面某项技能的完整步骤文本。 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 -- collectForm(entityKeyword, knownFieldsJson?):新增单据时弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填(值逐字照抄原话)。 -- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 +- collectForm(entityKeyword, knownFieldsJson?):新增单据时弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填(值逐字照抄原话)。用户点【保存】后系统自动校验并提交待办。 +- previewChange(action, entityKeyword, recordKeyword, fieldChinese?, newValue?):对**已有记录**的写操作生成预览卡(只读:绝不写入)。卡片带 ERP 同名按钮(保存/审核/反审核/作废/取消作废/删除),用户点了按钮系统才提交待办。action:update=改字段;invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 【硬规则】 1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。 -2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**;提议卡出现就停下,请用户点【确认】。 +2. 写操作一律人在环:你只能弹表单(collectForm)或出预览卡(previewChange),**绝不声称已保存/已提交/已完成**——写入只发生在用户点卡上按钮之后,且由 ERP 决定何时执行。卡片出现就停下,请用户核对后点按钮。 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。 -若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来。 +若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来;「已提交待办」的操作不要重复提交。 diff --git a/src/main/resources/skills/quote-create.md b/src/main/resources/skills/quote-create.md index 7b88fbd..ab066c9 100644 --- a/src/main/resources/skills/quote-create.md +++ b/src/main/resources/skills/quote-create.md @@ -3,7 +3,6 @@ 【技能:新建报价】 问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。 1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。 -2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。 -3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。 -4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。 -5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。 +2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【保存】,停下等待。 +3. 用户点保存后系统自动校验并提交待办(上下文会出现「已提交待办」),你无需再做任何写操作;绝不说「已生成报价单」。 +4. 用户问价格数字:请他在 ERP 里点【核价】得到;你自己不报任何价格数字。 diff --git a/src/main/resources/skills/record-create.md b/src/main/resources/skills/record-create.md index d7a8bed..003e4ca 100644 --- a/src/main/resources/skills/record-create.md +++ b/src/main/resources/skills/record-create.md @@ -4,4 +4,4 @@ 1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。 2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。 3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。 -4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。 +4. 表单弹出即停,请用户填完点【保存】;保存后系统自动校验并提交待办,你无需再做任何写操作,绝不说已完成。 diff --git a/src/main/resources/skills/record-status.md b/src/main/resources/skills/record-status.md index 4351b86..20d3588 100644 --- a/src/main/resources/skills/record-status.md +++ b/src/main/resources/skills/record-status.md @@ -1,14 +1,14 @@ 单据状态操作 作废/删除、复原、审核、反审核/销审某张已有单据 【技能:单据状态操作】 -动作对照(proposeWrite 的 action): +动作对照(previewChange 的 action): - 删除/取消/不要了 → invalid(作废,可复原) - 复原/恢复/取消作废 → cancelInvalid - 审核/审核通过 → examine - 反审核/销审/撤回审核 → cancelExamine - 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复 1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。 -2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。 -3. 工具提示多条匹配:念候选让用户选后再调一次。 -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 +2. previewChange(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。该工具只生成预览卡,不写入。 +3. 工具提示多条匹配:念候选让用户选后再调一次;提示状态不合法(如已审核过):如实转告用户,停下。 +4. 预览卡出现即停:请用户核对单据与状态变化后点卡上按钮(审核/作废/…);绝不说已完成。 消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。 diff --git a/src/main/resources/skills/record-update.md b/src/main/resources/skills/record-update.md index 5dd44ed..f931d61 100644 --- a/src/main/resources/skills/record-update.md +++ b/src/main/resources/skills/record-update.md @@ -2,7 +2,7 @@ 改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段 【技能:修改记录】 1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。 -2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。 +2. 齐了就 previewChange(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。该工具只生成预览卡,不写入。 3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。 -4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 +4. 预览卡出现即停:请用户核对整张表单(改动已高亮,可在卡上继续修正)后点【保存】;绝不说已保存/已完成。 注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。 diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index d8b299a..be8d815 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -187,6 +187,22 @@ body { .ffield .fkrow input { flex: 1; background: var(--bg); } .ffield .hint { font-size: 12px; color: var(--text2); margin-top: 2px; } +/* previewChange 预览卡 */ +.ffield.changed label { color: var(--accent); font-weight: 600; } +.ffield.changed input, .ffield.changed select { border-color: var(--accent); background: var(--accent-weak); } +.ffield .oldval { font-size: 12px; color: var(--text2); margin-top: 2px; text-decoration: line-through; } +.preview-ro { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; } +.preview-ro .l { color: var(--text2); min-width: 90px; } +.status-change { + margin-top: 10px; + padding: 8px 12px; + background: var(--accent-weak); + border-radius: 8px; + font-weight: 600; +} +.status-change .from { color: var(--text2); text-decoration: line-through; margin-right: 6px; } +.status-change .to { color: var(--accent); } + /* 输入区 */ #inputbar { background: var(--bg); padding: 8px 20px 16px; } #inputbox { @@ -527,8 +543,8 @@ function handleEvent(ev, bubble, buf) { bubble.classList.add("thinking"); bubble.textContent = "…"; return ""; - case "write_proposal": - addProposalCard(ev.opId, ev.summary); + case "change_preview": + addPreviewCard(ev); return buf; case "question": addQuestionCard(ev.question, ev.options || [], ev.allowFree !== false); @@ -545,14 +561,85 @@ function handleEvent(ev, bubble, buf) { } } -/* ================= 写提议卡 ================= */ -function addProposalCard(opId, summary) { +/* ================= previewChange 预览卡 ================= + 整表当前值 + 改动高亮(update 可继续在卡上修正字段)/ 单据摘要 + 状态变化高亮(状态类只读)。 + 点卡上按钮(保存/审核/作废/…)→ 确定性端点 /preview/{id}/save 重校验后写 ai_op_queue; + 点按钮前不写入任何数据。【不用了】仅关闭卡片(服务端预览 30 分钟自动过期)。 */ +function addPreviewCard(ev) { const card = el("div", "card"); - card.appendChild(el("div", "card-title", "待确认操作")); - card.appendChild(el("div", "muted", summary || "")); + card.appendChild(el("div", "card-title", ev.title || "写操作预览")); + if (ev.message) { + const m = el("div", "muted"); + m.style.whiteSpace = "pre-wrap"; + m.textContent = ev.message; + card.appendChild(m); + } + const controls = []; // {label, get()} —— 仅 editable 预览收集 + if (ev.editable) { + const grid = el("div", "form-grid"); + (ev.fields || []).forEach(f => { + const wrap = el("div", "ffield" + (f.changed ? " changed" : "")); + const lab = el("label"); + lab.textContent = f.label || f.name; + wrap.appendChild(lab); + const initial = f.changed ? (f.newValue || "") : (f.value || ""); + let getter; + if (f.type === "fkselect") { + const row = el("div", "fkrow"); + const inp = el("input"); + inp.readOnly = true; + inp.placeholder = "点击选择…"; + inp.value = initial; + const pick = el("button", "btn", "选择"); + pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; }); + row.appendChild(inp); + row.appendChild(pick); + wrap.appendChild(row); + getter = () => inp.value.trim(); + } else if (f.type === "select" && Array.isArray(f.options) && f.options.length) { + const sel = el("select"); + sel.appendChild(el("option", null, "")); + f.options.forEach(o => { + const op = el("option", null, o); + op.value = o; + sel.appendChild(op); + }); + sel.value = initial; + wrap.appendChild(sel); + getter = () => sel.value; + } else { + const inp = el("input"); + if (f.type === "number") { inp.type = "text"; inp.inputMode = "decimal"; } + else if (f.type === "date") inp.type = "date"; + inp.value = initial; + wrap.appendChild(inp); + getter = () => inp.value.trim(); + } + if (f.changed && f.value) wrap.appendChild(el("div", "oldval", "原值:" + f.value)); + grid.appendChild(wrap); + controls.push({ label: f.label || f.name, get: getter }); + }); + card.appendChild(grid); + } else { + (ev.fields || []).forEach(f => { + const row = el("div", "preview-ro"); + row.appendChild(el("span", "l", (f.label || f.name) + ":")); + row.appendChild(el("span", null, f.value == null ? "" : String(f.value))); + card.appendChild(row); + }); + if (ev.statusChange && ev.statusChange.label) { + const sc = el("div", "status-change"); + sc.appendChild(el("span", null, ev.statusChange.label + ":")); + sc.appendChild(el("span", "from", ev.statusChange.from || "")); + sc.appendChild(el("span", null, "→ ")); + sc.appendChild(el("span", "to", ev.statusChange.to || "")); + card.appendChild(sc); + } + } + const actions = el("div", "actions"); - const ok = el("button", "btn primary", "确认执行"); - const no = el("button", "btn", "取消"); + const ok = el("button", "btn primary", ev.buttonLabel || "保存"); + const no = el("button", "btn", "不用了"); actions.appendChild(ok); actions.appendChild(no); card.appendChild(actions); @@ -561,30 +648,35 @@ function addProposalCard(opId, summary) { messagesEl.appendChild(card); scrollBottom(); - const finish = (cls, text) => { - ok.disabled = no.disabled = true; - result.className = "result " + cls; - result.textContent = text; - scrollBottom(); - }; ok.onclick = async () => { ok.disabled = no.disabled = true; + result.className = "result"; + result.textContent = "提交中…"; try { - const r = await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/confirm", - { method: "POST", headers: authHeaders(false) }); + const fields = {}; + controls.forEach(c => { const v = c.get(); if (v) fields[c.label] = v; }); + const r = await fetch(BASE + "/api/agent/preview/" + encodeURIComponent(ev.previewId) + "/save", + { method: "POST", headers: authHeaders(true), body: JSON.stringify({ fields: fields }) }); const d = await r.json(); - finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status); + if (d.queued) { + result.className = "result ok"; + result.textContent = d.message || "已提交待办。"; + } else { + ok.disabled = no.disabled = false; + result.className = "result bad"; + result.textContent = d.error || "提交失败。"; + } } catch (e) { - finish("bad", "执行请求失败:" + e.message); + ok.disabled = no.disabled = false; + result.className = "result bad"; + result.textContent = "提交失败:" + e.message; } + scrollBottom(); }; - no.onclick = async () => { + no.onclick = () => { ok.disabled = no.disabled = true; - try { - await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/cancel", - { method: "POST", headers: authHeaders(false) }); - } catch (e) { } - finish("bad", "已取消"); + result.className = "result"; + result.textContent = "已关闭(未提交任何操作)。"; }; } @@ -674,7 +766,7 @@ function addFormCard(ev) { card.appendChild(grid); const actions = el("div", "actions"); - const submit = el("button", "btn primary", "提交"); + const submit = el("button", "btn primary", "保存"); const result = el("div", "result"); actions.appendChild(submit); card.appendChild(actions); @@ -700,9 +792,9 @@ function addFormCard(ev) { } submit.disabled = true; result.className = "result"; - result.textContent = "提交中…"; + result.textContent = "保存中…"; try { - // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码 + // 确定性保存:结构化字段直达 create 校验并写 ai_op_queue(不经 LLM 再编码);执行由 ERP 侧负责 const r = await fetch(BASE + "/api/agent/form/submit", { method: "POST", headers: authHeaders(true), @@ -713,19 +805,18 @@ function addFormCard(ev) { }) }); const d = await r.json(); - if (d.opId) { + if (d.queued) { result.className = "result ok"; - result.textContent = "已生成待确认提议。"; - addProposalCard(d.opId, d.summary); + result.textContent = d.message || "已提交待办。"; } else { submit.disabled = false; result.className = "result bad"; - result.textContent = d.error || "提交失败。"; + result.textContent = d.error || "保存失败。"; } } catch (e) { submit.disabled = false; result.className = "result bad"; - result.textContent = "提交失败:" + e.message; + result.textContent = "保存失败:" + e.message; } loadConvs(); }; diff --git a/src/test/java/com/xly/service/EventProjectionTest.java b/src/test/java/com/xly/service/EventProjectionTest.java index d005fea..358c077 100644 --- a/src/test/java/com/xly/service/EventProjectionTest.java +++ b/src/test/java/com/xly/service/EventProjectionTest.java @@ -241,6 +241,36 @@ class EventProjectionTest { } @Test + void previewCardPinsAndQueuedShowsReadOnlyProgress() { + InMemoryLedger log = new InMemoryLedger(); + EventLogChatMemory mem = memory(log); + log.append(CONV, "user", Map.of("text", "把必胜客电话改成13800138000")); + log.append(CONV, "skill_active", Map.of("name", "修改记录", "text", "【技能:修改记录】…")); + mem.add(toolCall("t1", "previewChange", "{\"action\":\"update\"}")); + mem.add(ToolExecutionResultMessage.from("t1", "previewChange", + "{\"type\":\"change_preview\",\"previewId\":\"pv1\",\"summary\":\"将【必胜客】的【联系电话】改为「13800138000」\"}")); + + String card = proj.activeCard(log.events(CONV)); + assertTrue(card.contains("预览卡") && card.contains("尚未写入"), "预览卡钉住且明示未写入"); + assertTrue(proj.historyView(log.events(CONV)).stream() + .anyMatch(h -> h.get("content").startsWith("【预览】")), "预览从工具结果同源进历史"); + + // 用户点【保存】→ queued:技能线摘下,待办行只读展示 sStatus + proj.setOpStatusLookup(opId -> "op1".equals(opId) ? "已提交,等待 ERP 处理" : null); + log.append(CONV, "queued", Map.of("opIds", java.util.List.of("op1"), + "description", "将【必胜客】的【联系电话】改为「13800138000」")); + card = proj.activeCard(log.events(CONV)); + assertFalse(card.contains("【技能:修改记录】"), "queued 后技能线摘下(AI 侧流程到写入队列为止)"); + assertTrue(card.contains("已提交待办") && card.contains("等待 ERP 处理"), "待办行附 sStatus 只读进度"); + assertTrue(proj.historyView(log.events(CONV)).stream() + .anyMatch(h -> h.get("content").startsWith("【已提交待办】"))); + assertTrue(proj.project("SYS", log.events(CONV)).stream() + .anyMatch(m -> m instanceof AiMessage a && a.text() != null && a.text().contains("绝不声称已执行成功")), + "LLM 正文里 queued 事件明示执行权在 ERP"); + proj.setOpStatusLookup(null); + } + + @Test void skillCardPinsFullTextUntilDone() { InMemoryLedger log = new InMemoryLedger(); log.append(CONV, "user", Map.of("text", "报价纸盒"));