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