From b7543403eef700f0caf27332c1b95533d3efec1f Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:34:58 +0800 Subject: [PATCH] P2: skill-ReAct — drop intent gate and deterministic pipelines --- bench/bench_ext.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- src/main/java/com/xly/agent/Intent.java | 63 --------------------------------------------------------------- src/main/java/com/xly/agent/ReActAgent.java | 12 +++++------- src/main/java/com/xly/config/AgentFactory.java | 26 ++++++++++++-------------- src/main/java/com/xly/service/ConversationService.java | 6 ++---- src/main/java/com/xly/service/EventProjectionService.java | 6 +++++- src/main/java/com/xly/service/IntentService.java | 176 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/main/java/com/xly/service/LlmJsonClient.java | 95 ----------------------------------------------------------------------------------------------- src/main/java/com/xly/service/SkillService.java | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/SlotFillService.java | 200 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/main/java/com/xly/service/StateService.java | 187 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/main/java/com/xly/service/SystemPromptService.java | 70 ++++++++++++++++++++++++++++++---------------------------------------- src/main/java/com/xly/tool/FormCollectTool.java | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/tool/UseSkillTool.java | 43 +++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/util/OkHttpUtil.java | 43 ------------------------------------------- src/main/java/com/xly/web/AgentChatController.java | 297 ++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/main/java/com/xly/web/OpController.java | 6 +----- src/main/resources/prompts/system.txt | 25 +++++++++++++++++++++++++ src/main/resources/skills/修改记录.md | 7 +++++++ src/main/resources/skills/单据状态操作.md | 13 +++++++++++++ src/main/resources/skills/新建单据.md | 6 ++++++ src/main/resources/skills/新建报价.md | 8 ++++++++ src/main/resources/skills/查询数据.md | 7 +++++++ src/test/java/com/xly/service/ConversationScopeTest.java | 3 +-- src/test/java/com/xly/service/SkillServiceTest.java | 42 ++++++++++++++++++++++++++++++++++++++++++ src/test/java/com/xly/tool/FormCollectDimensionTest.java | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 26 files changed, 596 insertions(+), 1118 deletions(-) delete mode 100644 src/main/java/com/xly/agent/Intent.java delete mode 100644 src/main/java/com/xly/service/IntentService.java delete mode 100644 src/main/java/com/xly/service/LlmJsonClient.java create mode 100644 src/main/java/com/xly/service/SkillService.java delete mode 100644 src/main/java/com/xly/service/SlotFillService.java delete mode 100644 src/main/java/com/xly/service/StateService.java create mode 100644 src/main/java/com/xly/tool/UseSkillTool.java delete mode 100644 src/main/java/com/xly/util/OkHttpUtil.java create mode 100644 src/main/resources/prompts/system.txt create mode 100644 src/main/resources/skills/修改记录.md create mode 100644 src/main/resources/skills/单据状态操作.md create mode 100644 src/main/resources/skills/新建单据.md create mode 100644 src/main/resources/skills/新建报价.md create mode 100644 src/main/resources/skills/查询数据.md create mode 100644 src/test/java/com/xly/service/SkillServiceTest.java create mode 100644 src/test/java/com/xly/tool/FormCollectDimensionTest.java diff --git a/bench/bench_ext.py b/bench/bench_ext.py index 465fa52..85013a9 100644 --- a/bench/bench_ext.py +++ b/bench/bench_ext.py @@ -25,19 +25,40 @@ MODEL = "qwen3.6-27b-iq3:latest" MAX_STEPS = 8 # ---------------- 与 bench50_v2 相同的生产复刻 prompt / 工具定义 ---------------- -from bench50_v2 import UNIFIED_PROMPT, ALL6 # noqa: E402 +import pathlib # noqa: E402 + +from bench50_v2 import UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES # noqa: E402 + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SKILLS_DIR = ROOT / "src/main/resources/skills" + +T_USESKILL = fn("useSkill", + "载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。", + {"skillName": {"type": "string", + "description": "技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据"}}, + ["skillName"]) + + +def load_skills(): + """{名称: (一句话用途, 正文)} —— 与生产 SkillService 同源读仓库 skill 文件。""" + out = {} + for p in sorted(SKILLS_DIR.glob("*.md")): + lines = p.read_text(encoding="utf-8").split("\n", 1) + out[p.stem] = (lines[0].strip(), lines[1].strip() if len(lines) > 1 else "") + return out def build_context(arch): - """返回 (system_prompt, tools)。new 架构落地后从仓库资源文件读取,避免复刻漂移。""" + """返回 (system_prompt, tools)。new 架构从仓库资源文件读取(与生产同源),避免复刻漂移。""" if arch == "old": return UNIFIED_PROMPT, ALL6 if arch == "new": - import pathlib - root = pathlib.Path(__file__).resolve().parent.parent - sp = (root / "src/main/resources/prompts/system.txt").read_text(encoding="utf-8") - tools_json = (root / "src/main/resources/prompts/tools-bench.json").read_text(encoding="utf-8") - return sp, json.loads(tools_json) + template = (ROOT / "src/main/resources/prompts/system.txt").read_text(encoding="utf-8") + 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 raise SystemExit(f"未知 arch: {arch}") @@ -97,7 +118,12 @@ def fixture_result(name, args): if name == "askUser": return "[问题已发给用户,等待回答,本轮到此为止。]" if name == "useSkill": - return "[skill 文本已载入上下文]" + skills = load_skills() + want = args.get("skillName", "") + for sname, (_, body) in skills.items(): + if sname == want or sname in want or want in sname: + return body + return "没有叫「" + want + "」的技能。可用技能:" + "、".join(skills) return "OK" @@ -328,6 +354,77 @@ CASES = [ ] +# ---------------- bench50 用例的全轨迹判分(新旧架构同口径对照) ---------------- +def judge_fifty(exp, calls, text): + """与 bench50_v2.judge 同语义,但看整条轨迹而非首步。useSkill 是自由动作,不计对错。""" + biz = [(n, a) for n, a in calls if n != "useSkill"] + actions = [(a or {}).get("action", "") for n, a in biz if n == "proposeWrite"] + names = [n for n, _ in biz] + ask = asked(calls, text) + if exp == "查询": + if any(n in READ_TOOLS_50 for n in names): + return "ok" + if not biz and text and not any(c.isdigit() for c in text): + return "ok" + return "fail" + if exp == "新增": + if "collectForm" in names or "create" in actions: + return "ok" + return "clarify" if ask else "fail" + if exp == "修改": + if "update" in actions or "askUser" in names or "lookupRecord" in names: + return "ok" + return "clarify" if ask else "fail" + if exp == "删除": + if any(a in ("invalid", "delete", "cancelInvalid") for a in actions): + return "ok" + return "clarify" if ask else "fail" + if exp == "审核": + if any(a in ("examine", "cancelExamine") for a in actions): + return "ok" + return "clarify" if ask else "fail" + if exp == "闲聊": + return "ok" if not biz and text else "fail" + if exp == "不清楚": + if "askUser" in names or (not biz and text): + return "ok" + return "fail" + return "fail" + + +READ_TOOLS_50 = {"findForms", "readFormData", "lookupRecord"} + + +def run_fifty(arch): + system_prompt, tools = build_context(arch) + print(f"bench50 全轨迹判分 arch={arch} model={MODEL}") + strict = 0 + lenient = 0 + per = {} + fails = [] + for utt, exp in FIFTY_CASES: + t0 = time.time() + calls, text, steps, err = run_trajectory(system_prompt, tools, [], utt) + dt = time.time() - t0 + v = "fail" if err else judge_fifty(exp, calls, text) + if err: + fails.append((utt, exp, f"ERROR {err}")) + strict += v == "ok" + lenient += v in ("ok", "clarify") + s, l, n = per.get(exp, (0, 0, 0)) + per[exp] = (s + (v == "ok"), l + (v in ("ok", "clarify")), n + 1) + mark = {"ok": "✓", "clarify": "◐", "fail": "✗"}[v] + traj = " → ".join(n for n, _ in calls) or "文字" + if v == "fail" and not err: + fails.append((utt, exp, traj + (" 「" + (text or "")[:60].replace("\n", " ") + "」" if text else ""))) + print(f"{mark} [{exp}] {utt} → {traj} ({steps}步 {dt:.0f}s)") + n = len(FIFTY_CASES) + print(f"\n[{arch}] bench50 全轨迹: 严格 {strict}/{n} = {strict/n*100:.0f}% 宽松 {lenient}/{n} = {lenient/n*100:.0f}%") + print("按意图分(严格/宽松/总数):", {k: f"{s}/{l}/{c}" for k, (s, l, c) in per.items()}) + for utt, exp, d in fails: + print(f" 失败: 「{utt}」[{exp}] → {d}") + + def main(): arch = "old" only = None @@ -336,6 +433,9 @@ def main(): arch = argv[argv.index("--arch") + 1] if "--only" in argv: only = argv[argv.index("--only") + 1] + if "--fifty" in argv: + run_fifty(arch) + return system_prompt, tools = build_context(arch) cases = [c for c in CASES if only is None or c["id"] == only] print(f"arch={arch} model={MODEL} cases={len(cases)}") diff --git a/src/main/java/com/xly/agent/Intent.java b/src/main/java/com/xly/agent/Intent.java deleted file mode 100644 index 3c507f9..0000000 --- a/src/main/java/com/xly/agent/Intent.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.xly.agent; - -import java.util.ArrayList; -import java.util.List; - -/** - * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。 - * - *

由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成 - * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**: - * 新增→确定性表单、操作已有单据→写槽位抽取+确定性 proposeWrite、查询/其他→ReAct agent。 - * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 - */ -public class Intent { - - /** 操作意图(4 类)。 */ - public static final String QUERY = "查询"; - public static final String CREATE = "新增"; - public static final String OPERATE = "操作已有单据"; - public static final String OTHER = "其他"; - - public static class Entity { - public String value; - public String role; // 客户/产品/物料/供应商/数量/尺寸/日期/金额/其他/未知 - public Entity() {} - public Entity(String value, String role) { this.value = value; this.role = role; } - } - - /** 写操作(修改/删除/审核)的专用槽位:比通用 entities 更贴合 propose* 工具的入参。 */ - public static class WriteSlots { - public String entityType = ""; // 记录所属实体:客户/供应商/物料/产品/单据… - public String record = ""; // 要操作的记录名(公司名/物料名/单号) - public String field = ""; // 要修改的字段中文名(删除/审核留空) - public String newValue = ""; // 修改后的新值(删除/审核留空) - } - - public String intent = OTHER; - public boolean failed = false; // true = 意图门本身失败(模型不可达/解析异常),非真实「其他」 - public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单 - public List entities = new ArrayList<>(); - public List missing = new ArrayList<>(); // 完成该意图还缺的关键信息 - - /** 取某业务角色的第一个实体值(如「客户」「产品」),没有则返回 null。 */ - public String firstValueByRole(String role) { - for (Entity e : entities) { - if (e != null && role.equals(e.role) && e.value != null && !e.value.isBlank()) { - return e.value.trim(); - } - } - return null; - } - - /** 所有实体值拼接(用于给下游 agent 做 grounding 描述)。 */ - public String describeEntities() { - StringBuilder sb = new StringBuilder(); - for (Entity e : entities) { - if (e == null || e.value == null || e.value.isBlank()) continue; - if (sb.length() > 0) sb.append(","); - sb.append(e.value).append("(").append(e.role == null ? "未知" : e.role).append(")"); - } - return sb.toString(); - } -} diff --git a/src/main/java/com/xly/agent/ReActAgent.java b/src/main/java/com/xly/agent/ReActAgent.java index 8d1191c..93f9ff6 100644 --- a/src/main/java/com/xly/agent/ReActAgent.java +++ b/src/main/java/com/xly/agent/ReActAgent.java @@ -5,16 +5,14 @@ import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.UserMessage; /** - * 单一 ReAct 智能体。 + * 单一 ReAct 智能体 —— 全部自由文本的唯一入口(没有意图门、没有确定性路由)。 * *

LangChain4j 的 {@code AiServices} 原生工具调用循环即 ReAct:模型自行决定是否调用工具、 - * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。 + * 调用哪个工具、以及何时给出最终答复。业务流程知识在技能文本里(useSkill 载入, + * 投影层钉在「进行中的流程」卡上跨轮续办);安全不变量在工具与确认端点内,与编排无关。 * - *

但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService}) - * 判类,新增/操作已有单据走确定性路径(collectForm / proposeWrite)完全绕过 ReAct; - * 本接口只负责「给定固定的 6 个工具,自己决定怎么用」。 - * - *

返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 + *

返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆 + * (事件日志投影,见 {@code EventLogChatMemory})。 */ public interface ReActAgent { diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index 85ba392..e533227 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -9,12 +9,14 @@ import com.xly.service.EventProjectionService; import com.xly.service.FormResolverService; import com.xly.service.LedgerService; import com.xly.service.OpService; +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.UseSkillTool; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.service.AiServices; import org.springframework.beans.factory.annotation.Qualifier; @@ -48,12 +50,14 @@ public class AgentFactory { private final KgQueryTool kgQueryTool; private final InteractionTool interactionTool; + private final SkillService skillService; public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, LedgerService ledger, EventProjectionService projection, SystemPromptService systemPromptService, ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, - ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) { + ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool, + SkillService skillService) { this.streamingModel = streamingModel; this.ledger = ledger; this.projection = projection; @@ -65,21 +69,20 @@ public class AgentFactory { this.mapper = mapper; this.kgQueryTool = kgQueryTool; this.interactionTool = interactionTool; - } - - public ReActAgent build(AgentIdentity identity) { - return build(identity, false); + this.skillService = skillService; } /** - * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations} - * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 + * 组装 ReAct agent:固定 7 工具(useSkill + 6)+ 唯一 system prompt。 + * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。 * + * @param convId 会话 id(useSkill 落 skill_active 事件需要) * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记, * 前端历史不显示 */ - public ReActAgent build(AgentIdentity identity, boolean internalUserTurn) { + public ReActAgent build(AgentIdentity identity, String convId, boolean internalUserTurn) { 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)}; @@ -95,12 +98,7 @@ public class AgentFactory { .build(); } - /** 供确定性「新增」路径直接构建 collectForm 表单(不经 LLM 工具选择)。 */ - public FormCollectTool formCollectTool(AgentIdentity identity) { - return new FormCollectTool(jdbc, resolver, identity, mapper); - } - - /** 供确定性「修改/作废/审核…」路径直接调 proposeWrite(不经 LLM 工具选择)。 */ + /** 供确定性「表单提交」端点直接调 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/ConversationService.java b/src/main/java/com/xly/service/ConversationService.java index 36616df..be758ad 100644 --- a/src/main/java/com/xly/service/ConversationService.java +++ b/src/main/java/com/xly/service/ConversationService.java @@ -30,17 +30,15 @@ public class ConversationService { private final RedisChatMemoryStore memoryStore; private final ObjectMapper mapper; private final LedgerService ledger; - private final StateService state; private final EventProjectionService projection; public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, - ObjectMapper mapper, LedgerService ledger, StateService state, + ObjectMapper mapper, LedgerService ledger, EventProjectionService projection) { this.redis = redis; this.memoryStore = memoryStore; this.mapper = mapper; this.ledger = ledger; - this.state = state; this.projection = projection; } @@ -137,7 +135,7 @@ public class ConversationService { redis.opsForHash().delete(CONVS_KEY + userId, convId); memoryStore.deleteMessages(convId); ledger.delete(convId); - state.delete(convId); + // 旧 chat:state 键随 30 天 TTL 自然过期(StateService 已随意图门废除) } /** diff --git a/src/main/java/com/xly/service/EventProjectionService.java b/src/main/java/com/xly/service/EventProjectionService.java index 96d789a..4d4577f 100644 --- a/src/main/java/com/xly/service/EventProjectionService.java +++ b/src/main/java/com/xly/service/EventProjectionService.java @@ -189,7 +189,11 @@ public class EventProjectionService { switch (type) { case "skill_active" -> skillText = str(ev.get("text")); case "skill_done" -> skillText = null; - case "form", "form_submit", "proposal", "confirm", "cancel" -> lastFlow = ev; + case "form", "form_submit", "proposal" -> lastFlow = ev; + case "confirm", "cancel" -> { // 流程终结:单据线与技能线一起摘下 + lastFlow = ev; + skillText = null; + } case "tool_result" -> { String name = str(ev.get("name")); if ("collectForm".equals(name) || "proposeWrite".equals(name)) { diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java deleted file mode 100644 index 0ab64d0..0000000 --- a/src/main/java/com/xly/service/IntentService.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.xly.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.xly.agent.Intent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图(4 类), 单据类型, 带角色的实体, 缺失信息}。 - * - *

只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。提示只给**类定义** - * (含有界的行业常识,如 定制品问价=新建报价),不写逐案纠错规则——错误种类无穷, - * 按案例打补丁写不完;观察到的错例进回归基准(bench50)而不是提示。 - * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。 - * - *

失败(模型不可达/JSON 异常)时返回 intent=其他 的空结果,调用方降级到通用 agent,绝不中断。 - */ -@Service -public class IntentService { - - private static final Logger log = LoggerFactory.getLogger(IntentService.class); - - private static final String SYSTEM = - "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n" - + "意图定义:\n" - + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n" - + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)" - + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n" - + "- 操作已有单据:对系统里已存在的单据/记录做修改、作废/删除、审核/反审核、复原。" - + "只要动作属于这些,即使还没说具体哪条记录也算本类(缺的信息之后澄清)。\n" - + "- 其他:闲聊、问候、与 ERP 业务无关,或只说名词、没有任何动作、无法判断想做什么。\n" - + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n" - + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价)," - + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。" - + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。"; - - private final LlmJsonClient llm; - - public IntentService(LlmJsonClient llm) { - this.llm = llm; - } - - /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ - public Intent classify(String utterance) { - return classify(utterance, null); - } - - /** 带会话状态摘要的分类:状态槽让「那张单子」这类跨轮指代可解。 */ - public Intent classify(String utterance, String stateDigest) { - Intent out = new Intent(); - if (utterance == null || utterance.isBlank()) { - return out; - } - JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema()); - if (n == null) { - log.warn("intent classify fell back to 其他 (model unavailable)"); - out.failed = true; // 门失败 ≠ 真实闲聊:下游护栏不能因此关闭 - return out; - } - String intent = n.path("intent").asText(Intent.OTHER); - out.intent = normalizeIntent(intent); - out.danju = n.path("danju").asText(""); - JsonNode es = n.path("entities"); - if (es.isArray()) { - for (JsonNode e : es) { - String v = e.path("value").asText(""); - String r = e.path("role").asText("未知"); - if (!v.isBlank()) { - out.entities.add(new Intent.Entity(v, r)); - } - } - } - JsonNode ms = n.path("missing"); - if (ms.isArray()) { - for (JsonNode m : ms) { - String v = m.asText(""); - if (!v.isBlank()) out.missing.add(v); - } - } - return out; - } - - private static final String WRITE_SYSTEM = - "你是 ERP 写操作的槽位抽取器。从用户话中抽取要操作的记录及改动:" - + "record=要操作的那条记录的名称(公司名/客户名/物料名/单号等);" - + "entityType=该记录属于哪类实体(客户/供应商/物料/产品/单据;拿不准就填 客户);" - + "field=要修改的字段中文名(如 电话/地址/简称/备注),删除或审核时留空;" - + "newValue=改成什么新值,删除或审核时留空。只输出 JSON,不要解释。"; - - /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ - public Intent.WriteSlots extractWrite(String utterance) { - return extractWrite(utterance, null); - } - - /** 带会话状态摘要的写槽位抽取(「把那张报价单作废」的 record 可从状态里补齐)。 */ - public Intent.WriteSlots extractWrite(String utterance, String stateDigest) { - Intent.WriteSlots w = new Intent.WriteSlots(); - if (utterance == null || utterance.isBlank()) { - return w; - } - JsonNode n = llm.completeJson(WRITE_SYSTEM, withState(utterance, stateDigest), writeSchema()); - if (n == null) { - return w; - } - w.entityType = n.path("entityType").asText(""); - w.record = n.path("record").asText(""); - w.field = n.path("field").asText(""); - w.newValue = n.path("newValue").asText(""); - return w; - } - - private Map writeSchema() { - Map props = new LinkedHashMap<>(); - props.put("entityType", Map.of("type", "string")); - props.put("record", Map.of("type", "string")); - props.put("field", Map.of("type", "string")); - props.put("newValue", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", props); - schema.put("required", List.of("record")); - return schema; - } - - /** 状态摘要作为输入前缀(有状态才加,单句冷启动时输入形态不变)。 */ - private static String withState(String utterance, String stateDigest) { - String u = utterance.trim(); - if (stateDigest == null || stateDigest.isBlank()) { - return u; - } - return "【会话状态】" + stateDigest + "\n【这句话】" + u; - } - - private static String normalizeIntent(String s) { - if (s == null) return Intent.OTHER; - s = s.trim(); - switch (s) { - case Intent.QUERY: - case Intent.CREATE: - case Intent.OPERATE: - case Intent.OTHER: - return s; - default: - return Intent.OTHER; - } - } - - private Map schema() { - Map entityProps = new LinkedHashMap<>(); - entityProps.put("value", Map.of("type", "string")); - entityProps.put("role", Map.of("type", "string", - "enum", List.of("客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知"))); - Map entityItem = new LinkedHashMap<>(); - entityItem.put("type", "object"); - entityItem.put("properties", entityProps); - entityItem.put("required", List.of("value", "role")); - - Map props = new LinkedHashMap<>(); - props.put("intent", Map.of("type", "string", - "enum", List.of("查询", "新增", "操作已有单据", "其他"))); - props.put("danju", Map.of("type", "string")); - props.put("entities", Map.of("type", "array", "items", entityItem)); - props.put("missing", Map.of("type", "array", "items", Map.of("type", "string"))); - - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", props); - schema.put("required", List.of("intent", "danju", "entities")); - return schema; - } -} diff --git a/src/main/java/com/xly/service/LlmJsonClient.java b/src/main/java/com/xly/service/LlmJsonClient.java deleted file mode 100644 index 276ef89..0000000 --- a/src/main/java/com/xly/service/LlmJsonClient.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.xly.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.xly.config.TracingChatModelListener; -import com.xly.util.OkHttpUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * OpenAI 兼容协议(/v1/chat/completions)的**受约束 JSON**补全(constrained decoding)。 - * - *

用 {@code response_format={type:"json_schema", json_schema:{schema}}} 做语法约束解码, - * 保证输出**一定**是 schema 合法的 JSON(Ollama 侧由 XGrammar 实现,违反 schema 的 token 概率直接置 0)—— - * 这是根治「把产品名塞进客户字段」这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下 - * 意图/实体抽取 8/8 正确、~2-3s/次(reasoning_effort=none)。 - * - *

用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与受约束槽位填充。 - * 主对话/查询走 LangChain4j 工具循环。 - */ -@Service -public class LlmJsonClient { - - private static final Logger log = LoggerFactory.getLogger(LlmJsonClient.class); - - private final ObjectMapper mapper; - private final TracingChatModelListener tracing; - private final OkHttpUtil http = OkHttpUtil.getInstance(10, 120, 30); - - @Value("${llm.base-url}") - private String baseUrl; - - @Value("${llm.api-key:ollama}") - private String apiKey; - - @Value("${llm.chat-model}") - private String model; - - public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) { - this.mapper = mapper; - this.tracing = tracing; - } - - /** - * 受约束 JSON 补全:低温度、response_format=json_schema、非流式。 - * - * @param system 系统提示(角色 + 抽取规则) - * @param user 用户话 - * @param schema JSON Schema(Map 结构,序列化进 response_format.json_schema.schema) - * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程) - */ - public JsonNode completeJson(String system, String user, Map schema) { - long t0 = System.nanoTime(); - String startTs = Instant.now().toString(); - try { - Map body = new LinkedHashMap<>(); - body.put("model", model); - body.put("stream", false); - body.put("temperature", 0.1); - body.put("top_p", 0.9); - body.put("reasoning_effort", "none"); - body.put("response_format", Map.of( - "type", "json_schema", - "json_schema", Map.of("name", "output", "schema", schema))); - body.put("messages", List.of( - Map.of("role", "system", "content", system), - Map.of("role", "user", "content", user))); - - String json = mapper.writeValueAsString(body); - String resp = http.postJson(baseUrl + "/chat/completions", apiKey, json); - JsonNode root = mapper.readTree(resp); - JsonNode usage = root.path("usage"); - tracing.record(model, t0, startTs, - usage.path("prompt_tokens").isNumber() ? usage.path("prompt_tokens").asInt() : null, - usage.path("completion_tokens").isNumber() ? usage.path("completion_tokens").asInt() : null, - null); - String content = root.path("choices").path(0).path("message").path("content").asText(""); - if (content.isBlank()) { - log.warn("llm json completion: empty content"); - return null; - } - return mapper.readTree(content); - } catch (Exception e) { - tracing.record(model, t0, startTs, null, null, e.getMessage()); - return null; - } - } -} diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java new file mode 100644 index 0000000..d4792ec --- /dev/null +++ b/src/main/java/com/xly/service/SkillService.java @@ -0,0 +1,130 @@ +package com.xly.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。 + * + *

文件格式:{@code <名称>.md},首行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。 + * 默认从 classpath {@code skills/*.md} 读取(随包发布、缓存一次);配置 + * {@code xly.skills.dir} 指向文件系统目录后改从该目录**每次现读**——改文件即热更,不用重启。 + */ +@Service +public class SkillService { + + private static final Logger log = LoggerFactory.getLogger(SkillService.class); + + public record Skill(String name, String brief, String body) { } + + @Value("${xly.skills.dir:}") + private String skillsDir; + + private volatile List classpathCache; + + public List all() { + if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) { + return loadFromDir(Path.of(skillsDir)); + } + List c = classpathCache; + if (c == null) { + c = loadFromClasspath(); + classpathCache = c; + } + return c; + } + + /** 按名精确匹配,其次互相包含(「新建报价单」→「新建报价」)。找不到返回 null。 */ + public Skill find(String name) { + if (name == null || name.isBlank()) { + return null; + } + String n = name.trim(); + List skills = all(); + for (Skill s : skills) { + if (s.name().equals(n)) { + return s; + } + } + for (Skill s : skills) { + if (n.contains(s.name()) || s.name().contains(n)) { + return s; + } + } + return null; + } + + /** system prompt 的技能索引(一行一个:名称:用途)。 */ + public String indexLines() { + StringBuilder sb = new StringBuilder(); + for (Skill s : all()) { + sb.append("- ").append(s.name()).append(":").append(s.brief).append('\n'); + } + return sb.toString(); + } + + private List loadFromDir(Path dir) { + List out = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + files.filter(p -> p.getFileName().toString().endsWith(".md")).sorted().forEach(p -> { + try { + Skill s = parse(p.getFileName().toString(), Files.readString(p, StandardCharsets.UTF_8)); + if (s != null) { + out.add(s); + } + } catch (IOException e) { + log.warn("skill 读取失败 {}: {}", p, e.getMessage()); + } + }); + } catch (IOException e) { + log.warn("skill 目录读取失败 {}: {}", dir, e.getMessage()); + } + return out; + } + + private List loadFromClasspath() { + List out = new ArrayList<>(); + try { + Resource[] resources = new PathMatchingResourcePatternResolver() + .getResources("classpath:skills/*.md"); + for (Resource r : resources) { + try { + Skill s = parse(r.getFilename(), new String( + r.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + if (s != null) { + out.add(s); + } + } catch (IOException e) { + log.warn("skill 读取失败 {}: {}", r.getFilename(), e.getMessage()); + } + } + } catch (IOException e) { + log.warn("classpath skills 加载失败: {}", e.getMessage()); + } + out.sort((a, b) -> a.name().compareTo(b.name())); + return out; + } + + private static Skill parse(String filename, String content) { + if (filename == null || content == null || content.isBlank()) { + return null; + } + String name = filename.endsWith(".md") ? filename.substring(0, filename.length() - 3) : filename; + int nl = content.indexOf('\n'); + String brief = (nl < 0 ? content : content.substring(0, nl)).trim(); + String body = nl < 0 ? "" : content.substring(nl + 1).trim(); + return new Skill(name, brief, body); + } +} diff --git a/src/main/java/com/xly/service/SlotFillService.java b/src/main/java/com/xly/service/SlotFillService.java deleted file mode 100644 index bdd9377..0000000 --- a/src/main/java/com/xly/service/SlotFillService.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.xly.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.xly.agent.Intent; -import org.springframework.stereotype.Service; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 新增单据的**确定性槽位映射**:把意图门已抽好的「带角色实体」+ 正则解析的尺寸/数量, - * 按表单真实字段落位,产出 collectForm 的预填 JSON。 - * - *

为什么不用 LLM 做这步:实测 qwen3:14b 能可靠区分「产品 vs 客户」(意图门已给出角色), - * 但对「长和宽50cm,高5cm」这类**尺寸拆分**始终出错(塞进备注/单价)。这类窄任务用 Java 正则 + - * 角色映射远比让模型受约束填槽可靠——把不确定的判断交给能确定处理的代码,是治『不智能』的关键手法之一。 - */ -@Service -public class SlotFillService { - - private final ObjectMapper mapper; - - public SlotFillService(ObjectMapper mapper) { - this.mapper = mapper; - } - - // 标注式尺寸:"长和宽50"、"高5cm"、"长:50" - private static final Pattern DIM_LABELED = - Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)"); - // 位置式尺寸:"50*30*5"、"50x30x5"、"50×30×5" - private static final Pattern DIM_POS = - Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); - - /** - * 依据意图门的角色实体 + 正则,确定性地构建新增表单的预填字段(中文字段名 -> 值)。 - * - * @param utterance 用户原话(用于解析尺寸) - * @param entity 单据/实体类型(如 报价 / 客户) - * @param fields {@code FormResolverService.businessFields}(含 label/fk/type) - * @param it 意图门结果(提供带角色的实体) - */ - public Map buildCreateFields(String utterance, String entity, - List> fields, Intent it) { - Map known = new LinkedHashMap<>(); - if (fields == null || fields.isEmpty()) { - return known; - } - // 干净字段名 -> 原始 label(供尺寸/数量按名定位) - Map cleanToLabel = new LinkedHashMap<>(); - for (Map f : fields) { - String label = str(f.get("label")); - if (label != null && !label.isBlank()) { - cleanToLabel.putIfAbsent(cleanLabel(label), label); - } - } - - String cust = it == null ? null : it.firstValueByRole("客户"); - String prod = it == null ? null : it.firstValueByRole("产品"); - String mat = it == null ? null : it.firstValueByRole("物料"); - String sup = it == null ? null : it.firstValueByRole("供应商"); - - // 1) 外键角色字段:客户/产品/物料/供应商 → 对应 FK 字段 - for (Map f : fields) { - String fk = str(f.get("fk")); - String label = str(f.get("label")); - if (fk == null || label == null) continue; - if ("elecustomer".equals(fk) && cust != null) known.putIfAbsent(label, cust); - else if ("eleproduct".equals(fk) && prod != null) known.putIfAbsent(label, prod); - else if ("elematerials".equals(fk) && mat != null) known.putIfAbsent(label, mat); - else if ("elesupplier".equals(fk) && sup != null) known.putIfAbsent(label, sup); - } - - // 2) 数量:意图门角色=数量 → 只留数字 - String qty = it == null ? null : it.firstValueByRole("数量"); - if (qty != null) { - String lbl = cleanToLabel.get("数量"); - if (lbl != null) { - String d = qty.replaceAll("[^0-9.]", ""); - if (!d.isEmpty()) known.putIfAbsent(lbl, d); - } - } - - // 3) 尺寸:正则从原话解析 长/宽/高 - Map dims = parseDimensions(utterance); - for (Map.Entry e : dims.entrySet()) { - String lbl = cleanToLabel.get(e.getKey()); // "长"/"宽"/"高" - if (lbl != null) known.putIfAbsent(lbl, e.getValue()); - } - - // 4) 名称字段:新增某实体本身(如"新增客户叫X")时,实体名要落到它的名称字段 - // (客户表本身没有 elecustomer 外键列,故不会被上面的 FK 环填上,需在此补)。 - String nameLabel = findNameField(fields); - if (nameLabel != null && !known.containsKey(nameLabel)) { - String selfRole = danjuToRole(entity); - String nameVal = (selfRole != null && it != null) ? it.firstValueByRole(selfRole) : null; - if (nameVal == null && cust == null && prod == null && mat == null && sup == null) { - nameVal = firstEntityValue(it); // 兜底:单据类型不含角色词时用首个实体 - } - if (nameVal != null) { - known.put(nameLabel, nameVal); - } - } - return known; - } - - /** 单据/实体类型 → 它自身的业务角色(新增该实体时,该角色的实体值即其名称)。 */ - private static String danjuToRole(String entity) { - if (entity == null) return null; - if (entity.contains("客户")) return "客户"; - if (entity.contains("供应商")) return "供应商"; - if (entity.contains("物料")) return "物料"; - if (entity.contains("产品")) return "产品"; - return null; - } - - /** 解析尺寸 → {长,宽,高} 的数字(cm/mm 单位忽略,只取数值)。 */ - public Map parseDimensions(String utterance) { - Map out = new LinkedHashMap<>(); - if (utterance == null || utterance.isBlank()) return out; - Matcher m = DIM_LABELED.matcher(utterance); - boolean any = false; - while (m.find()) { - String labels = m.group(1); - String val = m.group(2); - for (int i = 0; i < labels.length(); i++) { - char c = labels.charAt(i); - String key = dimKey(c); - if (key != null) { - out.put(key, val); - any = true; - } - } - } - if (!any) { - Matcher p = DIM_POS.matcher(utterance); - if (p.find()) { - out.put("长", p.group(1)); - out.put("宽", p.group(2)); - if (p.group(3) != null) out.put("高", p.group(3)); - } - } - return out; - } - - private static String dimKey(char c) { - switch (c) { - case '长': return "长"; - case '宽': return "宽"; - case '高': - case '厚': - case '深': return "高"; - default: return null; - } - } - - /** 名称字段:优先 label 以「名称」结尾且非外键的字段;否则第一个 label 含「名称」的。 */ - private static String findNameField(List> fields) { - for (Map f : fields) { - String label = str(f.get("label")); - String fk = str(f.get("fk")); - if (label != null && label.endsWith("名称") && (fk == null || fk.isBlank())) { - return label; - } - } - for (Map f : fields) { - String label = str(f.get("label")); - if (label != null && label.contains("名称")) return label; - } - return null; - } - - private static String firstEntityValue(Intent it) { - if (it == null) return null; - for (Intent.Entity e : it.entities) { - if (e != null && e.value != null && !e.value.isBlank()) return e.value.trim(); - } - return null; - } - - /** 序列化为 knownFieldsJson,供 {@code FormCollectTool.collectForm} 预填。 */ - public String toJson(Map known) { - try { - return mapper.writeValueAsString(known); - } catch (Exception e) { - return "{}"; - } - } - - private static String cleanLabel(String s) { - if (s == null) return ""; - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); - } - - private static String str(Object o) { - return o == null ? null : o.toString(); - } -} diff --git a/src/main/java/com/xly/service/StateService.java b/src/main/java/com/xly/service/StateService.java deleted file mode 100644 index 58846ba..0000000 --- a/src/main/java/com/xly/service/StateService.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.xly.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.xly.agent.Intent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.List; - -/** - * 会话状态槽 —— 由**代码**(而非模型总结)维护的少量结构化状态:上轮意图 / 最近实体 / 在办单据。 - * 注入两处:意图门的输入前缀(让「那张单子」这类指代可解),agent 用户消息尾部(稳住多轮上下文)。 - * - *

键:Redis HASH {@code chat:state:{convId}}(fields: intent/danju/entities/doc),30 天 TTL。 - */ -@Service -public class StateService { - - private static final Logger log = LoggerFactory.getLogger(StateService.class); - private static final String PREFIX = "chat:state:"; - private static final Duration TTL = Duration.ofDays(30); - private static final int MAX_ENTITIES = 8; - - private final StringRedisTemplate redis; - private final ObjectMapper mapper; - - public StateService(StringRedisTemplate redis, ObjectMapper mapper) { - this.redis = redis; - this.mapper = mapper; - } - - /** 记录本轮意图门结果(上轮意图 + 单据类型)。 */ - public void recordIntent(String convId, String intent, String danju) { - try { - String key = PREFIX + convId; - redis.opsForHash().put(key, "intent", intent == null ? "" : intent); - redis.opsForHash().put(key, "danju", danju == null ? "" : danju); - redis.expire(key, TTL); - } catch (Exception e) { - log.warn("state recordIntent failed (conv={}): {}", convId, e.getMessage()); - } - } - - /** 合并本轮识别到的实体(最新在前、按 值+角色 去重、封顶 {@value #MAX_ENTITIES} 个)。 */ - public void mergeEntities(String convId, List entities) { - if (entities == null || entities.isEmpty()) { - return; - } - try { - String key = PREFIX + convId; - ArrayNode merged = mapper.createArrayNode(); - for (Intent.Entity e : entities) { - if (e == null || e.value == null || e.value.isBlank()) continue; - ObjectNode n = merged.addObject(); - n.put("value", e.value.trim()); - n.put("role", e.role == null ? "未知" : e.role); - } - Object old = redis.opsForHash().get(key, "entities"); - if (old != null) { - JsonNode arr = mapper.readTree(old.toString()); - for (JsonNode n : arr) { - if (merged.size() >= MAX_ENTITIES) break; - boolean dup = false; - for (JsonNode m : merged) { - if (m.path("value").asText().equals(n.path("value").asText()) - && m.path("role").asText().equals(n.path("role").asText())) { - dup = true; - break; - } - } - if (!dup) merged.add(n); - } - } - redis.opsForHash().put(key, "entities", mapper.writeValueAsString(merged)); - redis.expire(key, TTL); - } catch (Exception e) { - log.warn("state mergeEntities failed (conv={}): {}", convId, e.getMessage()); - } - } - - /** 设置在办单据(entity=单据/实体类型,record=记录名/单号,stage=collecting|proposed|executed|failed|cancelled)。 */ - public void setActiveDoc(String convId, String entity, String record, String opId, String stage) { - try { - String key = PREFIX + convId; - ObjectNode doc = mapper.createObjectNode(); - doc.put("entity", entity == null ? "" : entity); - doc.put("record", record == null ? "" : record); - doc.put("opId", opId == null ? "" : opId); - doc.put("stage", stage == null ? "" : stage); - redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); - redis.expire(key, TTL); - } catch (Exception e) { - log.warn("state setActiveDoc failed (conv={}): {}", convId, e.getMessage()); - } - } - - /** 确认/取消后推进在办单据阶段(仅当 opId 匹配当前在办单据)。 */ - public void updateDocStage(String convId, String opId, String stage) { - if (convId == null || convId.isBlank() || opId == null || opId.isBlank()) { - return; - } - try { - String key = PREFIX + convId; - Object old = redis.opsForHash().get(key, "doc"); - if (old == null) { - return; - } - ObjectNode doc = (ObjectNode) mapper.readTree(old.toString()); - if (!opId.equals(doc.path("opId").asText(""))) { - return; - } - doc.put("stage", stage); - redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); - } catch (Exception e) { - log.warn("state updateDocStage failed (conv={}): {}", convId, e.getMessage()); - } - } - - /** 状态摘要(一行中文),空状态返回 ""。喂意图门 + 附在 agent 用户消息尾部。 */ - public String digest(String convId) { - try { - String key = PREFIX + convId; - Object intent = redis.opsForHash().get(key, "intent"); - Object danju = redis.opsForHash().get(key, "danju"); - Object doc = redis.opsForHash().get(key, "doc"); - Object entities = redis.opsForHash().get(key, "entities"); - StringBuilder sb = new StringBuilder(); - if (intent != null && !intent.toString().isBlank()) { - sb.append("上轮意图=").append(intent); - if (danju != null && !danju.toString().isBlank()) { - sb.append("(").append(danju).append(")"); - } - } - if (doc != null) { - JsonNode d = mapper.readTree(doc.toString()); - String ent = d.path("entity").asText(""); - String rec = d.path("record").asText(""); - String stage = d.path("stage").asText(""); - if (!ent.isBlank() || !rec.isBlank()) { - if (sb.length() > 0) sb.append(";"); - sb.append("在办单据=").append(ent); - if (!rec.isBlank()) sb.append("【").append(rec).append("】"); - if (!stage.isBlank()) sb.append("(").append(stageZh(stage)).append(")"); - } - } - if (entities != null) { - JsonNode arr = mapper.readTree(entities.toString()); - StringBuilder es = new StringBuilder(); - for (JsonNode n : arr) { - if (es.length() > 0) es.append("、"); - es.append(n.path("role").asText("未知")).append("=").append(n.path("value").asText("")); - } - if (es.length() > 0) { - if (sb.length() > 0) sb.append(";"); - sb.append("最近实体=").append(es); - } - } - return sb.toString(); - } catch (Exception e) { - return ""; - } - } - - private static String stageZh(String stage) { - switch (stage) { - case "collecting": return "填表中"; - case "proposed": return "待确认"; - case "executed": return "已执行"; - case "failed": return "执行失败"; - case "cancelled": return "已取消"; - default: return stage; - } - } - - public void delete(String convId) { - try { - redis.delete(PREFIX + convId); - } catch (Exception ignore) { - } - } -} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index edc3b06..b4ca10f 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -1,67 +1,57 @@ package com.xly.service; +import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; /** - * 构建 agent 的**唯一** system prompt。 + * 构建 agent 的**唯一** system prompt:模板 {@code prompts/system.txt} + 业务域地图(DB,缓存一次) + * + 技能索引({@link SkillService},目录模式下热更)。 * - *

所有轮次共用同一份提示 + 同一组 6 个工具(findForms/readFormData/lookupRecord/ - * collectForm/proposeWrite/askUser):n=50 基准显示按意图分版收不到准确率收益, - * 反而破坏 KV-cache 前缀稳定性。提示遵循弱模型提示工程:关键规则前置、正向表述、 - * 工具清单短;域知识(业务域地图、业务常识)常驻,不做按类分版。 + *

所有轮次共用同一份提示 + 同一组固定工具(useSkill/findForms/readFormData/lookupRecord/ + * collectForm/proposeWrite/askUser):稳定前缀利于 KV-cache;流程知识在技能文本里, + * 由模型按需 useSkill 载入,不再压进 prompt。基准脚本(bench/bench_ext.py --arch new) + * 读取同一模板与技能文件,避免复刻漂移。 */ @Service public class SystemPromptService { private final JdbcTemplate jdbc; + private final SkillService skills; - private volatile String prompt; + private volatile String template; + private volatile String domainMap; - public SystemPromptService(JdbcTemplate jdbc) { + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) { this.jdbc = jdbc; + this.skills = skills; } - private static final String HEADER = - "【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**," - + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n" - + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n"; - public String prompt() { - String p = prompt; - if (p == null) { - p = render(); - prompt = p; + String t = template; + if (t == null) { + t = loadTemplate(); + template = t; + } + String d = domainMap; + if (d == null) { + d = renderDomainMap(); + domainMap = d; } - return p; + return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", skills.indexLines()); } - private String render() { - return HEADER + """ - - 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 - %s - 【业务常识】 - - 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。 - - 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。 - - 【可用工具(只有这些)】 - - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 - - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。 - - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 - - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 - - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 - - 【准则】 - 1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。 - 2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。 - 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。 - 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。 - """.formatted(renderDomainMap()); + private static String loadTemplate() { + try { + return new String(new ClassPathResource("prompts/system.txt") + .getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("system prompt 模板缺失(prompts/system.txt)", e); + } } private String renderDomainMap() { diff --git a/src/main/java/com/xly/tool/FormCollectTool.java b/src/main/java/com/xly/tool/FormCollectTool.java index 761e693..2ffe2b0 100644 --- a/src/main/java/com/xly/tool/FormCollectTool.java +++ b/src/main/java/com/xly/tool/FormCollectTool.java @@ -13,6 +13,8 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, @@ -33,6 +35,14 @@ public class FormCollectTool { private static final int MAX_FIELDS = 40; + // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理) + // 标注式:"长和宽50"、"高5cm"、"长:50" + private static final Pattern DIM_LABELED = + Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)"); + // 位置式:"50*30*5"、"50x30x5"、"50×30×5" + 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 FormResolverService resolver; private final AgentIdentity identity; @@ -58,6 +68,7 @@ public class FormCollectTool { return err("缺少实体类型。"); } Map known = parseKnown(knownFieldsJson); + expandDimensions(known); Map form = resolver.resolveMasterForm(entityKeyword.trim()); if (form == null) { return err("找不到「" + entityKeyword + "」对应的可新建表单。"); @@ -246,6 +257,54 @@ public class FormCollectTool { return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); } + /** + * 预填值里的尺寸表达(「50*30*5」「长和宽50,高5cm」)确定性拆成 长/宽/高 三个键 + * (模型拆不对是实测结论——原 SlotFillService 的正则迁于此)。已有的 长/宽/高 预填不覆盖。 + */ + static void expandDimensions(Map known) { + Map dims = new LinkedHashMap<>(); + for (String v : known.values()) { + if (v == null || v.isBlank()) { + continue; + } + Matcher m = DIM_LABELED.matcher(v); + boolean any = false; + while (m.find()) { + String labels = m.group(1); + String val = m.group(2); + for (int i = 0; i < labels.length(); i++) { + String key = dimKey(labels.charAt(i)); + if (key != null) { + dims.putIfAbsent(key, val); + any = true; + } + } + } + if (!any) { + Matcher p = DIM_POS.matcher(v); + if (p.find()) { + dims.putIfAbsent("长", p.group(1)); + dims.putIfAbsent("宽", p.group(2)); + if (p.group(3) != null) { + dims.putIfAbsent("高", p.group(3)); + } + } + } + } + dims.forEach(known::putIfAbsent); + } + + private static String dimKey(char c) { + switch (c) { + case '长': return "长"; + case '宽': return "宽"; + case '高': + case '厚': + case '深': return "高"; + default: return null; + } + } + private String err(String msg) { Map m = new LinkedHashMap<>(); m.put("error", msg); diff --git a/src/main/java/com/xly/tool/UseSkillTool.java b/src/main/java/com/xly/tool/UseSkillTool.java new file mode 100644 index 0000000..a44e62d --- /dev/null +++ b/src/main/java/com/xly/tool/UseSkillTool.java @@ -0,0 +1,43 @@ +package com.xly.tool; + +import com.xly.service.LedgerService; +import com.xly.service.SkillService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.util.Map; + +/** + * useSkill 工具 —— 把某项技能(业务流程的权威步骤文本)载入上下文。 + * + *

返回技能全文(本轮立即可用),同时落 {@code skill_active} 事件:投影层把激活技能的全文 + * 钉在「进行中的流程」卡上(见 EventProjectionService),跨轮续办不丢步骤;提议 executed/cancelled + * 或新技能激活时摘下。 + * + *

由 {@code AgentFactory} 按请求新建:携带会话 id 用于落账。 + */ +public class UseSkillTool { + + private final SkillService skills; + private final LedgerService ledger; + private final String convId; + + public UseSkillTool(SkillService skills, LedgerService ledger, String convId) { + this.skills = skills; + this.ledger = ledger; + this.convId = convId; + } + + @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" + + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。") + public String useSkill(@P("技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据") String skillName) { + SkillService.Skill s = skills.find(skillName); + if (s == null) { + StringBuilder sb = new StringBuilder("没有叫「").append(skillName).append("」的技能。可用技能:\n"); + sb.append(skills.indexLines()); + return sb.toString(); + } + ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body())); + return s.body(); + } +} diff --git a/src/main/java/com/xly/util/OkHttpUtil.java b/src/main/java/com/xly/util/OkHttpUtil.java deleted file mode 100644 index b1d5d32..0000000 --- a/src/main/java/com/xly/util/OkHttpUtil.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.xly.util; - -import okhttp3.*; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -/** - * OkHttp 薄封装:目前仅 {@code LlmJsonClient} 用它同步 POST JSON。 - */ -public class OkHttpUtil { - - private final OkHttpClient client; - - private OkHttpUtil(long connectTimeout, long readTimeout, long writeTimeout) { - client = new OkHttpClient.Builder() - .connectTimeout(connectTimeout, TimeUnit.SECONDS) - .readTimeout(readTimeout, TimeUnit.SECONDS) - .writeTimeout(writeTimeout, TimeUnit.SECONDS) - .build(); - } - - public static OkHttpUtil getInstance(long connectTimeout, long readTimeout, long writeTimeout) { - return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout); - } - - /** POST JSON,带 Bearer 鉴权(OpenAI 兼容端点;Ollama 忽略该头,云端网关需要真实 key)。 */ - public String postJson(String url, String bearerToken, String json) throws IOException { - RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8")); - Request.Builder reqBuilder = new Request.Builder().url(url).post(body); - if (bearerToken != null && !bearerToken.isBlank()) { - reqBuilder.header("Authorization", "Bearer " + bearerToken); - } - Request request = reqBuilder.build(); - try (Response response = client.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message()); - } - ResponseBody rb = response.body(); - return rb != null ? rb.string() : ""; - } - } -} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index e218253..5450612 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -3,18 +3,12 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; -import com.xly.agent.Intent; import com.xly.agent.ReActAgent; import com.xly.config.AgentFactory; import com.xly.service.AuthzService; import com.xly.service.ConversationService; -import com.xly.service.FormResolverService; -import com.xly.service.IntentService; import com.xly.service.LedgerService; import com.xly.service.OpService; -import com.xly.service.SlotFillService; -import com.xly.service.StateService; -import com.xly.tool.FormCollectTool; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.tool.ToolExecution; import org.slf4j.Logger; @@ -29,7 +23,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -41,18 +34,17 @@ import java.util.regex.Pattern; /** * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * - *

编排(架构 §5 意图门 + 确定性路由):每轮先用 {@link IntentService} 做**受约束 JSON** - * 的 4 类意图+实体分类,再确定性路由: - *

- * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 + *

编排:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent + * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。 + * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。 + * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*} + * 确定性端点、身份内省 fail-closed、工具内鉴权。 * - *

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

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

帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 + * {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。 */ @RestController @RequestMapping("/api/agent") @@ -68,28 +60,17 @@ public class AgentChatController { private final ObjectMapper mapper; private final ConversationService conversations; private final OpService ops; - private final IntentService intentService; - private final SlotFillService slotFill; - private final FormResolverService resolver; private final LedgerService ledger; - private final StateService state; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, - ConversationService conversations, OpService ops, - IntentService intentService, SlotFillService slotFill, - FormResolverService resolver, LedgerService ledger, - StateService state) { + ConversationService conversations, OpService ops, LedgerService ledger) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; - this.intentService = intentService; - this.slotFill = slotFill; - this.resolver = resolver; this.ledger = ledger; - this.state = state; } public static class ChatReq { @@ -118,9 +99,9 @@ public class AgentChatController { exec.submit(() -> { try { - route(emitter, convId, identity, userInput); + runAgentAttempt(emitter, convId, identity, userInput, true); } catch (Exception e) { - log.error("agent route failed (conv={})", convId, e); + log.error("agent chat failed (conv={})", convId, e); send(emitter, "error", "服务异常:" + e.getMessage()); emitter.complete(); } @@ -163,8 +144,7 @@ public class AgentChatController { parts.append(k).append("=").append(v); } }); - String userText = "提交「" + entity + "」新增表单:" + parts; - conversations.touch(identity.userId(), convId, userText); + conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单"); ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString())); String fieldsJson; @@ -183,7 +163,6 @@ public class AgentChatController { ops.attachConversation(opId, convId); String summary = r.path("summary").asText(""); ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); - state.setActiveDoc(convId, entity, "", opId, "proposed"); out.put("opId", opId); out.put("summary", summary); out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。")); @@ -198,108 +177,17 @@ public class AgentChatController { return out; } - /** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */ - private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { - // 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。 - String digest = state.digest(convId); - Intent it = intentService.classify(userInput, digest); - state.recordIntent(convId, it.intent, it.danju); - state.mergeEntities(convId, it.entities); - log.info("intent(conv={}): {} / {} / entities={} / missing={}", - convId, it.intent, it.danju, it.describeEntities(), it.missing); - - switch (it.intent) { - case Intent.CREATE: - if (handleCreate(emitter, convId, identity, userInput, it)) { - return; - } - // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 - runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false); - return; - case Intent.OPERATE: - handleWrite(emitter, convId, identity, userInput, it, digest); - return; - case Intent.QUERY: - runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); - return; - default: - // 其他/分类失败:原文交给 agent,尽量不丢能力。门失败时不能顺带关掉反编造护栏。 - runAgent(emitter, convId, identity, withState(userInput, digest), it.failed); - } - } - - /** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */ - private static String withState(String text, String digest) { - if (digest == null || digest.isBlank()) { - return text; - } - return text + "\n\n(会话状态,仅供参考:" + digest + ")"; - } - /** - * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, - * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 - */ - private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity, - String userInput, Intent it) { - String entity = it.danju == null ? "" : it.danju.trim(); - if (entity.isEmpty()) { - return false; - } - Map form = resolver.resolveMasterForm(entity); - if (form == null) { - return false; - } - String table = String.valueOf(form.get("sDataSource")); - // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。 - List> fields = resolver.businessFields(table, 40); - Map known = slotFill.buildCreateFields(userInput, entity, fields, it); - - FormCollectTool fct = agentFactory.formCollectTool(identity); - String payload = fct.collectForm(entity, slotFill.toJson(known)); - try { - JsonNode r = mapper.readTree(payload); - if ("form_collect".equals(r.path("type").asText(""))) { - sendEvent(emitter, mapper.convertValue(r, Map.class)); - String hint = "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"; - send(emitter, "token", hint); - ledger.append(convId, "form", Map.of("entity", entity, "message", hint)); - state.setActiveDoc(convId, entity, "", "", "collecting"); - send(emitter, "done", ""); - emitter.complete(); - return true; - } - // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。 - String err = r.path("error").asText(""); - if (!err.isBlank()) { - send(emitter, "token", err); - ledger.append(convId, "assistant", Map.of("text", err)); - send(emitter, "done", ""); - emitter.complete(); - return true; - } - } catch (Exception e) { - log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage()); - } - return false; - } - - /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */ - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, - String text, boolean queryGuard) { - runAgentAttempt(emitter, convId, identity, text, queryGuard, true); - } - - /** - * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): - * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数, - * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。 + * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对 + * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术 + * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没 + * proposeWrite → 附纠正提示。 */ private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, - String text, boolean queryGuard, boolean allowRetry) { + String text, boolean allowRetry) { try { // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示 - ReActAgent agent = agentFactory.build(identity, !allowRetry); + ReActAgent agent = agentFactory.build(identity, convId, !allowRetry); AtomicInteger toolCalls = new AtomicInteger(); AtomicBoolean proposed = new AtomicBoolean(false); TokenStream ts = agent.chat(convId, text); @@ -311,13 +199,13 @@ public class AgentChatController { .onCompleteResponse(resp -> { String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null ? "" : resp.aiMessage().text(); - if (queryGuard && toolCalls.get() == 0 && hasDigits(answer)) { + if (toolCalls.get() == 0 && hasDigits(answer)) { if (allowRetry) { log.warn("anti-fab retry (conv={}): zero tools + digits", convId); send(emitter, "reset", ""); runAgentAttempt(emitter, convId, identity, "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text, - true, false); + false); return; } send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); @@ -354,138 +242,6 @@ public class AgentChatController { return false; } - /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ - private String ground(String userInput, Intent it) { - StringBuilder g = new StringBuilder(userInput); - g.append("\n\n(意图分析,仅供参考:意图=").append(it.intent); - if (it.danju != null && !it.danju.isBlank()) { - g.append(",单据/实体类型=").append(it.danju); - } - // 用 角色=值 的形式,避免下游把"值(角色)"整体误当作参数值 - StringBuilder pairs = new StringBuilder(); - for (Intent.Entity e : it.entities) { - if (e == null || e.value == null || e.value.isBlank()) continue; - if (pairs.length() > 0) pairs.append(","); - pairs.append(e.role == null ? "其他" : e.role).append("=").append(e.value); - } - if (pairs.length() > 0) { - g.append(",识别到 ").append(pairs); - } - g.append("。调用工具时请用上面的**值**作为参数(不要把角色名或括号写进参数),实体角色不要弄错。)"); - return g.toString(); - } - - /** - * 「操作已有单据」处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), - * 齐了就确定性调 proposeWrite(它会自定位主表/记录), - * 缺了就**确定性问一次**并停下——两头都不进失控循环。 - */ - private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, - String userInput, Intent it, String digest) { - Intent.WriteSlots w = intentService.extractWrite(userInput, digest); - log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", - convId, w.entityType, w.record, w.field, w.newValue); - - String action = deriveWriteAction(userInput); - String ent = pickEntity(w.entityType, it.danju); - - // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) - if ("update".equals(action)) { - java.util.List need = new java.util.ArrayList<>(); - if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); - if (isBlank(w.newValue)) need.add("改成什么新值"); - if (!need.isEmpty()) { - clarifyWrite(emitter, convId, userInput, "修改", w.record, need); - return; - } - } else if (isBlank(w.record)) { - clarifyWrite(emitter, convId, userInput, actionVerb(action), "", - java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); - return; - } - - // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 - String result = agentFactory.proposeWriteTool(identity) - .proposeWrite(action, ent, w.record, w.field, w.newValue, null); - emitWriteResult(emitter, convId, userInput, ent, w.record, result); - } - - /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ - private void emitWriteResult(SseEmitter emitter, String convId, String userInput, - String ent, String record, String result) { - 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(""); - Map card = new LinkedHashMap<>(); - card.put("type", "write_proposal"); - card.put("opId", opId); - card.put("summary", summary); - sendEvent(emitter, card); - send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); - state.setActiveDoc(convId, ent, record, opId, "proposed"); - } else { - String err = r.path("error").asText("无法完成该操作。"); - send(emitter, "token", err); - ledger.append(convId, "assistant", Map.of("text", err)); - } - } catch (Exception e) { - send(emitter, "error", "服务异常:" + e.getMessage()); - } - send(emitter, "done", ""); - emitter.complete(); - } - - /** 实体类型:抽取到的太泛(单据/记录/数据/单)时用意图门的 danju 兜底。 */ - private static String pickEntity(String entityType, String danju) { - boolean generic = entityType == null || entityType.isBlank() - || entityType.equals("单据") || entityType.equals("记录") - || entityType.equals("数据") || entityType.equals("单"); - if (!generic) return entityType.trim(); - return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); - } - - /** 从用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删;「删除」默认=作废,安全可复原)。 */ - private String deriveWriteAction(String text) { - String t = text == null ? "" : text; - if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; - if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; - if (t.contains("作废")) return "invalid"; - if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; - if (t.contains("审核") || t.contains("审批")) return "examine"; - if (t.contains("删除") || t.contains("删掉") || t.contains("删了")) return "invalid"; - return "update"; - } - - private static String actionVerb(String action) { - switch (action) { - case "invalid": return "作废"; - case "cancelInvalid": return "复原"; - case "examine": return "审核"; - case "cancelExamine": return "销审"; - case "delete": return "删除"; - default: return "操作"; - } - } - - private void clarifyWrite(SseEmitter emitter, String convId, String userInput, - String verb, String record, java.util.List need) { - String who = isBlank(record) ? "" : ("(记录:" + record + ")"); - String text = "要" + verb + who + ",我还需要您补充:" + String.join("、", need) - + "。请一起告诉我,我再为你生成待确认的操作。"; - send(emitter, "token", text); - ledger.append(convId, "clarify", Map.of("text", text)); - send(emitter, "done", ""); - emitter.complete(); - } - - private static boolean isBlank(String s) { - return s == null || s.isBlank(); - } - private static String firstNonBlank(String a, String b) { if (a != null && !a.isBlank()) return a; return b; @@ -507,23 +263,18 @@ public class AgentChatController { String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { ops.attachConversation(opId, convId); - String summary = r.path("summary").asText(""); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); - card.put("summary", summary); + card.put("summary", r.path("summary").asText("")); sendEvent(emitter, card); proposed.set(true); - state.setActiveDoc(convId, "", summary, opId, "proposed"); } } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { JsonNode r = mapper.readTree(te.result()); String type = r.path("type").asText(""); - if ("question".equals(type)) { - sendEvent(emitter, mapper.convertValue(r, Map.class)); - } else if ("form_collect".equals(type)) { + if ("question".equals(type) || "form_collect".equals(type)) { sendEvent(emitter, mapper.convertValue(r, Map.class)); - state.setActiveDoc(convId, r.path("entity").asText(""), "", "", "collecting"); } } } catch (Exception e) { diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index e0232a0..c251b6e 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -10,7 +10,6 @@ import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import com.xly.service.LedgerService; import com.xly.service.OpService; -import com.xly.service.StateService; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; import org.slf4j.Logger; @@ -51,7 +50,6 @@ public class OpController { private final AuditService audit; private final ObjectMapper mapper; private final LedgerService ledger; - private final StateService state; private final AuthzService authz; private final ConversationService conversations; private final FormResolverService resolver; @@ -61,7 +59,7 @@ public class OpController { private boolean execStagingEnabled; public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, - LedgerService ledger, StateService state, + LedgerService ledger, AuthzService authz, ConversationService conversations, FormResolverService resolver) { this.ops = ops; @@ -69,7 +67,6 @@ public class OpController { this.audit = audit; this.mapper = mapper; this.ledger = ledger; - this.state = state; this.authz = authz; this.conversations = conversations; this.resolver = resolver; @@ -86,7 +83,6 @@ public class OpController { "status", status, "msg", msg == null ? "" : msg, "description", description == null ? "" : description)); - state.updateDocStage(conv, opId, status); } catch (Exception e) { log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); } diff --git a/src/main/resources/prompts/system.txt b/src/main/resources/prompts/system.txt new file mode 100644 index 0000000..22d2198 --- /dev/null +++ b/src/main/resources/prompts/system.txt @@ -0,0 +1,25 @@ +【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。 + +你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。 + +【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】 +{DOMAIN_MAP} +【技能清单(业务流程的权威步骤,用 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。 +- askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。 + +【硬规则】 +1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。 +2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**;提议卡出现就停下,请用户点【确认】。 +3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。 +4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。 + +若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来。 diff --git a/src/main/resources/skills/修改记录.md b/src/main/resources/skills/修改记录.md new file mode 100644 index 0000000..c9c869e --- /dev/null +++ b/src/main/resources/skills/修改记录.md @@ -0,0 +1,7 @@ +改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段 +【技能:修改记录】 +1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。 +2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。 +3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。 +4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 +注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。 diff --git a/src/main/resources/skills/单据状态操作.md b/src/main/resources/skills/单据状态操作.md new file mode 100644 index 0000000..dbf0543 --- /dev/null +++ b/src/main/resources/skills/单据状态操作.md @@ -0,0 +1,13 @@ +作废/删除、复原、审核、反审核/销审某张已有单据 +【技能:单据状态操作】 +动作对照(proposeWrite 的 action): +- 删除/取消/不要了 → invalid(作废,可复原) +- 复原/恢复/取消作废 → cancelInvalid +- 审核/审核通过 → examine +- 反审核/销审/撤回审核 → cancelExamine +- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复 +1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。 +2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。 +3. 工具提示多条匹配:念候选让用户选后再调一次。 +4. 提议卡出现即停,请用户点【确认】;绝不说已完成。 +消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。 diff --git a/src/main/resources/skills/新建单据.md b/src/main/resources/skills/新建单据.md new file mode 100644 index 0000000..943112b --- /dev/null +++ b/src/main/resources/skills/新建单据.md @@ -0,0 +1,6 @@ +新建报价以外的单据或资料(客户/供应商/物料/订单…) +【技能:新建单据】 +1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。 +2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。 +3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。 +4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。 diff --git a/src/main/resources/skills/新建报价.md b/src/main/resources/skills/新建报价.md new file mode 100644 index 0000000..9bd1fbf --- /dev/null +++ b/src/main/resources/skills/新建报价.md @@ -0,0 +1,8 @@ +用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价 +【技能:新建报价】 +问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。 +1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。 +2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。 +3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。 +4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。 +5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。 diff --git a/src/main/resources/skills/查询数据.md b/src/main/resources/skills/查询数据.md new file mode 100644 index 0000000..10aae88 --- /dev/null +++ b/src/main/resources/skills/查询数据.md @@ -0,0 +1,7 @@ +查数量/概况、找记录、看单条详情、翻页时的标准查法 +【技能:查询数据】 +1. 不确定该查哪张表单:先 findForms(业务关键词);同名多张时优先选检索结果靠前的那张。 +2. 问总数/概况:readFormData 且 keyword 留空;找某个名称的记录:readFormData 填 keyword;要某条记录的完整信息或某个字段(电话/销售员…):lookupRecord。 +3. 用户要下一页/更多:readFormData 同参数、page 加 1。 +4. 答案里的每个数字都必须来自本轮工具结果;工具没查到就如实说没查到。 +5. 回答用记录名称,不用内部 id。给出答案即停。 diff --git a/src/test/java/com/xly/service/ConversationScopeTest.java b/src/test/java/com/xly/service/ConversationScopeTest.java index 644c0e8..4beb00c 100644 --- a/src/test/java/com/xly/service/ConversationScopeTest.java +++ b/src/test/java/com/xly/service/ConversationScopeTest.java @@ -22,8 +22,7 @@ class ConversationScopeTest { private ConversationService service(StringRedisTemplate redis) { return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(), - mock(LedgerService.class), mock(StateService.class), - new EventProjectionService(new ObjectMapper())); + mock(LedgerService.class), new EventProjectionService(new ObjectMapper())); } private static AgentIdentity user(String id) { diff --git a/src/test/java/com/xly/service/SkillServiceTest.java b/src/test/java/com/xly/service/SkillServiceTest.java new file mode 100644 index 0000000..b54f61a --- /dev/null +++ b/src/test/java/com/xly/service/SkillServiceTest.java @@ -0,0 +1,42 @@ +package com.xly.service; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */ +class SkillServiceTest { + + private final SkillService skills = new SkillService(); + + @Test + void allFiveSkillsLoadFromClasspath() { + List all = skills.all(); + assertEquals(5, all.size()); + for (String name : new String[]{"查询数据", "新建报价", "新建单据", "修改记录", "单据状态操作"}) { + SkillService.Skill s = skills.find(name); + assertNotNull(s, name + " 必须存在"); + assertTrue(s.brief().length() > 4, "首行=一句话用途"); + assertTrue(s.body().contains("【技能:"), "正文为技能全文"); + } + } + + @Test + void fuzzyNameMatches() { + assertEquals("新建报价", skills.find("新建报价单").name(), "「新建报价单」→ 新建报价"); + assertEquals("修改记录", skills.find("修改").name()); + assertNull(skills.find("不存在的技能")); + } + + @Test + void indexHasOneLinePerSkill() { + String idx = skills.indexLines(); + assertEquals(5, idx.strip().split("\n").length); + assertTrue(idx.contains("- 新建报价:")); + } +} diff --git a/src/test/java/com/xly/tool/FormCollectDimensionTest.java b/src/test/java/com/xly/tool/FormCollectDimensionTest.java new file mode 100644 index 0000000..f9a8abe --- /dev/null +++ b/src/test/java/com/xly/tool/FormCollectDimensionTest.java @@ -0,0 +1,68 @@ +package com.xly.tool; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * 尺寸拆分正则(自 SlotFillService 迁入):模型拆不对「长和宽50cm,高5cm」是实测结论, + * 预填值里的尺寸表达必须由代码确定性拆成 长/宽/高。 + */ +class FormCollectDimensionTest { + + private static Map known(String... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + @Test + void positionalSpecSplitsIntoLwh() { + Map m = known("规格", "60*40*30"); + FormCollectTool.expandDimensions(m); + assertEquals("60", m.get("长")); + assertEquals("40", m.get("宽")); + assertEquals("30", m.get("高")); + assertEquals("60*40*30", m.get("规格"), "原值保留"); + } + + @Test + void labeledSpecWithSharedValueAndUnits() { + Map m = known("尺寸", "长和宽50cm,高5cm"); + FormCollectTool.expandDimensions(m); + assertEquals("50", m.get("长")); + assertEquals("50", m.get("宽")); + assertEquals("5", m.get("高")); + } + + @Test + void multiplicationSignsAndDecimals() { + Map m = known("规格", "50×30x5.5"); + FormCollectTool.expandDimensions(m); + assertEquals("50", m.get("长")); + assertEquals("30", m.get("宽")); + assertEquals("5.5", m.get("高")); + } + + @Test + void existingExplicitDimsAreNotOverwritten() { + Map m = known("长", "99", "规格", "60*40*30"); + FormCollectTool.expandDimensions(m); + assertEquals("99", m.get("长"), "模型已明确给的 长 不被覆盖"); + assertEquals("40", m.get("宽")); + } + + @Test + void plainValuesAreUntouched() { + Map m = known("数量", "5000", "产品名称", "大16开画册", "联系电话", "0571-88881234"); + FormCollectTool.expandDimensions(m); + assertFalse(m.containsKey("长"), "普通数值/名称/电话不产生尺寸"); + assertEquals("大16开画册", m.get("产品名称"), "值逐字保留"); + } +} -- libgit2 0.22.2