Commit b7543403eef700f0caf27332c1b95533d3efec1f

Authored by zichun
1 parent 7e8560c4

P2: skill-ReAct — drop intent gate and deterministic pipelines

- single ReAct entry for all free text: 7 fixed tools (useSkill + 6);
  flow knowledge lives in 5 skill files (resources/skills/*.md, first
  line = brief for prompt index; xly.skills.dir overrides for hot reload)
- useSkill returns skill body and logs skill_active; projection pins the
  active skill full text on the flow card until confirm/cancel/new skill
- system prompt from template prompts/system.txt (domain map + skill
  index); bench reads the same files to avoid replica drift
- delete IntentService/Intent/SlotFillService/LlmJsonClient/StateService/
  OkHttpUtil and route()/handleCreate/handleWrite/deriveWriteAction/
  ground/withState; dimension-split regex migrated into FormCollectTool
  prefill assist (model provably fails dimension splitting)
- anti-fabrication guards now unconditional (zero-tools+digits retry,
  WRITE_CLAIM check); invariant layer and button endpoints untouched
- bench_ext: --arch new (same-source prompt/skills) and --fifty
  (bench50 cases judged on full trajectory for both arches)
bench/bench_ext.py
@@ -25,19 +25,40 @@ MODEL = "qwen3.6-27b-iq3:latest" @@ -25,19 +25,40 @@ MODEL = "qwen3.6-27b-iq3:latest"
25 MAX_STEPS = 8 25 MAX_STEPS = 8
26 26
27 # ---------------- 与 bench50_v2 相同的生产复刻 prompt / 工具定义 ---------------- 27 # ---------------- 与 bench50_v2 相同的生产复刻 prompt / 工具定义 ----------------
28 -from bench50_v2 import UNIFIED_PROMPT, ALL6 # noqa: E402 28 +import pathlib # noqa: E402
  29 +
  30 +from bench50_v2 import UNIFIED_PROMPT, ALL6, DOMAIN_MAP, fn, CASES as FIFTY_CASES # noqa: E402
  31 +
  32 +ROOT = pathlib.Path(__file__).resolve().parent.parent
  33 +SKILLS_DIR = ROOT / "src/main/resources/skills"
  34 +
  35 +T_USESKILL = fn("useSkill",
  36 + "载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等"
  37 + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。",
  38 + {"skillName": {"type": "string",
  39 + "description": "技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据"}},
  40 + ["skillName"])
  41 +
  42 +
  43 +def load_skills():
  44 + """{名称: (一句话用途, 正文)} —— 与生产 SkillService 同源读仓库 skill 文件。"""
  45 + out = {}
  46 + for p in sorted(SKILLS_DIR.glob("*.md")):
  47 + lines = p.read_text(encoding="utf-8").split("\n", 1)
  48 + out[p.stem] = (lines[0].strip(), lines[1].strip() if len(lines) > 1 else "")
  49 + return out
29 50
30 51
31 def build_context(arch): 52 def build_context(arch):
32 - """返回 (system_prompt, tools)。new 架构落地后从仓库资源文件读取,避免复刻漂移。""" 53 + """返回 (system_prompt, tools)。new 架构从仓库资源文件读取(与生产同源),避免复刻漂移。"""
33 if arch == "old": 54 if arch == "old":
34 return UNIFIED_PROMPT, ALL6 55 return UNIFIED_PROMPT, ALL6
35 if arch == "new": 56 if arch == "new":
36 - import pathlib  
37 - root = pathlib.Path(__file__).resolve().parent.parent  
38 - sp = (root / "src/main/resources/prompts/system.txt").read_text(encoding="utf-8")  
39 - tools_json = (root / "src/main/resources/prompts/tools-bench.json").read_text(encoding="utf-8")  
40 - return sp, json.loads(tools_json) 57 + template = (ROOT / "src/main/resources/prompts/system.txt").read_text(encoding="utf-8")
  58 + skills = load_skills()
  59 + index = "".join(f"- {name}:{brief}\n" for name, (brief, _) in skills.items())
  60 + sp = template.replace("{DOMAIN_MAP}", DOMAIN_MAP).replace("{SKILL_INDEX}", index)
  61 + return sp, [T_USESKILL] + ALL6
41 raise SystemExit(f"未知 arch: {arch}") 62 raise SystemExit(f"未知 arch: {arch}")
42 63
43 64
@@ -97,7 +118,12 @@ def fixture_result(name, args): @@ -97,7 +118,12 @@ def fixture_result(name, args):
97 if name == "askUser": 118 if name == "askUser":
98 return "[问题已发给用户,等待回答,本轮到此为止。]" 119 return "[问题已发给用户,等待回答,本轮到此为止。]"
99 if name == "useSkill": 120 if name == "useSkill":
100 - return "[skill 文本已载入上下文]" 121 + skills = load_skills()
  122 + want = args.get("skillName", "")
  123 + for sname, (_, body) in skills.items():
  124 + if sname == want or sname in want or want in sname:
  125 + return body
  126 + return "没有叫「" + want + "」的技能。可用技能:" + "、".join(skills)
101 return "OK" 127 return "OK"
102 128
103 129
@@ -328,6 +354,77 @@ CASES = [ @@ -328,6 +354,77 @@ CASES = [
328 ] 354 ]
329 355
330 356
  357 +# ---------------- bench50 用例的全轨迹判分(新旧架构同口径对照) ----------------
  358 +def judge_fifty(exp, calls, text):
  359 + """与 bench50_v2.judge 同语义,但看整条轨迹而非首步。useSkill 是自由动作,不计对错。"""
  360 + biz = [(n, a) for n, a in calls if n != "useSkill"]
  361 + actions = [(a or {}).get("action", "") for n, a in biz if n == "proposeWrite"]
  362 + names = [n for n, _ in biz]
  363 + ask = asked(calls, text)
  364 + if exp == "查询":
  365 + if any(n in READ_TOOLS_50 for n in names):
  366 + return "ok"
  367 + if not biz and text and not any(c.isdigit() for c in text):
  368 + return "ok"
  369 + return "fail"
  370 + if exp == "新增":
  371 + if "collectForm" in names or "create" in actions:
  372 + return "ok"
  373 + return "clarify" if ask else "fail"
  374 + if exp == "修改":
  375 + if "update" in actions or "askUser" in names or "lookupRecord" in names:
  376 + return "ok"
  377 + return "clarify" if ask else "fail"
  378 + if exp == "删除":
  379 + if any(a in ("invalid", "delete", "cancelInvalid") for a in actions):
  380 + return "ok"
  381 + return "clarify" if ask else "fail"
  382 + if exp == "审核":
  383 + if any(a in ("examine", "cancelExamine") for a in actions):
  384 + return "ok"
  385 + return "clarify" if ask else "fail"
  386 + if exp == "闲聊":
  387 + return "ok" if not biz and text else "fail"
  388 + if exp == "不清楚":
  389 + if "askUser" in names or (not biz and text):
  390 + return "ok"
  391 + return "fail"
  392 + return "fail"
  393 +
  394 +
  395 +READ_TOOLS_50 = {"findForms", "readFormData", "lookupRecord"}
  396 +
  397 +
  398 +def run_fifty(arch):
  399 + system_prompt, tools = build_context(arch)
  400 + print(f"bench50 全轨迹判分 arch={arch} model={MODEL}")
  401 + strict = 0
  402 + lenient = 0
  403 + per = {}
  404 + fails = []
  405 + for utt, exp in FIFTY_CASES:
  406 + t0 = time.time()
  407 + calls, text, steps, err = run_trajectory(system_prompt, tools, [], utt)
  408 + dt = time.time() - t0
  409 + v = "fail" if err else judge_fifty(exp, calls, text)
  410 + if err:
  411 + fails.append((utt, exp, f"ERROR {err}"))
  412 + strict += v == "ok"
  413 + lenient += v in ("ok", "clarify")
  414 + s, l, n = per.get(exp, (0, 0, 0))
  415 + per[exp] = (s + (v == "ok"), l + (v in ("ok", "clarify")), n + 1)
  416 + mark = {"ok": "✓", "clarify": "◐", "fail": "✗"}[v]
  417 + traj = " → ".join(n for n, _ in calls) or "文字"
  418 + if v == "fail" and not err:
  419 + fails.append((utt, exp, traj + (" 「" + (text or "")[:60].replace("\n", " ") + "」" if text else "")))
  420 + print(f"{mark} [{exp}] {utt} → {traj} ({steps}步 {dt:.0f}s)")
  421 + n = len(FIFTY_CASES)
  422 + print(f"\n[{arch}] bench50 全轨迹: 严格 {strict}/{n} = {strict/n*100:.0f}% 宽松 {lenient}/{n} = {lenient/n*100:.0f}%")
  423 + print("按意图分(严格/宽松/总数):", {k: f"{s}/{l}/{c}" for k, (s, l, c) in per.items()})
  424 + for utt, exp, d in fails:
  425 + print(f" 失败: 「{utt}」[{exp}] → {d}")
  426 +
  427 +
331 def main(): 428 def main():
332 arch = "old" 429 arch = "old"
333 only = None 430 only = None
@@ -336,6 +433,9 @@ def main(): @@ -336,6 +433,9 @@ def main():
336 arch = argv[argv.index("--arch") + 1] 433 arch = argv[argv.index("--arch") + 1]
337 if "--only" in argv: 434 if "--only" in argv:
338 only = argv[argv.index("--only") + 1] 435 only = argv[argv.index("--only") + 1]
  436 + if "--fifty" in argv:
  437 + run_fifty(arch)
  438 + return
339 system_prompt, tools = build_context(arch) 439 system_prompt, tools = build_context(arch)
340 cases = [c for c in CASES if only is None or c["id"] == only] 440 cases = [c for c in CASES if only is None or c["id"] == only]
341 print(f"arch={arch} model={MODEL} cases={len(cases)}") 441 print(f"arch={arch} model={MODEL} cases={len(cases)}")
src/main/java/com/xly/agent/Intent.java deleted
1 -package com.xly.agent;  
2 -  
3 -import java.util.ArrayList;  
4 -import java.util.List;  
5 -  
6 -/**  
7 - * 第 0 阶段「意图 + 实体」抽取结果(架构 §5 的 intent gate)。  
8 - *  
9 - * <p>由 {@code IntentService} 用受约束 JSON 一次得出:把一句自然语言压成  
10 - * {意图, 单据/实体类型, 带业务角色的实体清单, 还缺什么}。下游据此做**确定性路由**:  
11 - * 新增→确定性表单、操作已有单据→写槽位抽取+确定性 proposeWrite、查询/其他→ReAct agent。  
12 - * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。  
13 - */  
14 -public class Intent {  
15 -  
16 - /** 操作意图(4 类)。 */  
17 - public static final String QUERY = "查询";  
18 - public static final String CREATE = "新增";  
19 - public static final String OPERATE = "操作已有单据";  
20 - public static final String OTHER = "其他";  
21 -  
22 - public static class Entity {  
23 - public String value;  
24 - public String role; // 客户/产品/物料/供应商/数量/尺寸/日期/金额/其他/未知  
25 - public Entity() {}  
26 - public Entity(String value, String role) { this.value = value; this.role = role; }  
27 - }  
28 -  
29 - /** 写操作(修改/删除/审核)的专用槽位:比通用 entities 更贴合 propose* 工具的入参。 */  
30 - public static class WriteSlots {  
31 - public String entityType = ""; // 记录所属实体:客户/供应商/物料/产品/单据…  
32 - public String record = ""; // 要操作的记录名(公司名/物料名/单号)  
33 - public String field = ""; // 要修改的字段中文名(删除/审核留空)  
34 - public String newValue = ""; // 修改后的新值(删除/审核留空)  
35 - }  
36 -  
37 - public String intent = OTHER;  
38 - public boolean failed = false; // true = 意图门本身失败(模型不可达/解析异常),非真实「其他」  
39 - public String danju = ""; // 单据/实体类型,如 报价/客户/物料/销售订单  
40 - public List<Entity> entities = new ArrayList<>();  
41 - public List<String> missing = new ArrayList<>(); // 完成该意图还缺的关键信息  
42 -  
43 - /** 取某业务角色的第一个实体值(如「客户」「产品」),没有则返回 null。 */  
44 - public String firstValueByRole(String role) {  
45 - for (Entity e : entities) {  
46 - if (e != null && role.equals(e.role) && e.value != null && !e.value.isBlank()) {  
47 - return e.value.trim();  
48 - }  
49 - }  
50 - return null;  
51 - }  
52 -  
53 - /** 所有实体值拼接(用于给下游 agent 做 grounding 描述)。 */  
54 - public String describeEntities() {  
55 - StringBuilder sb = new StringBuilder();  
56 - for (Entity e : entities) {  
57 - if (e == null || e.value == null || e.value.isBlank()) continue;  
58 - if (sb.length() > 0) sb.append(",");  
59 - sb.append(e.value).append("(").append(e.role == null ? "未知" : e.role).append(")");  
60 - }  
61 - return sb.toString();  
62 - }  
63 -}  
src/main/java/com/xly/agent/ReActAgent.java
@@ -5,16 +5,14 @@ import dev.langchain4j.service.TokenStream; @@ -5,16 +5,14 @@ import dev.langchain4j.service.TokenStream;
5 import dev.langchain4j.service.UserMessage; 5 import dev.langchain4j.service.UserMessage;
6 6
7 /** 7 /**
8 - * 单一 ReAct 智能体 8 + * 单一 ReAct 智能体 —— 全部自由文本的唯一入口(没有意图门、没有确定性路由)
9 * 9 *
10 * <p>LangChain4j 的 {@code AiServices} 原生工具调用循环即 ReAct:模型自行决定是否调用工具、 10 * <p>LangChain4j 的 {@code AiServices} 原生工具调用循环即 ReAct:模型自行决定是否调用工具、
11 - * 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。 11 + * 调用哪个工具、以及何时给出最终答复。业务流程知识在技能文本里(useSkill 载入,
  12 + * 投影层钉在「进行中的流程」卡上跨轮续办);安全不变量在工具与确认端点内,与编排无关。
12 * 13 *
13 - * <p>但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService})  
14 - * 判类,新增/操作已有单据走确定性路径(collectForm / proposeWrite)完全绕过 ReAct;  
15 - * 本接口只负责「给定固定的 6 个工具,自己决定怎么用」。  
16 - *  
17 - * <p>返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。 14 + * <p>返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆
  15 + * (事件日志投影,见 {@code EventLogChatMemory})。
18 */ 16 */
19 public interface ReActAgent { 17 public interface ReActAgent {
20 18
src/main/java/com/xly/config/AgentFactory.java
@@ -9,12 +9,14 @@ import com.xly.service.EventProjectionService; @@ -9,12 +9,14 @@ import com.xly.service.EventProjectionService;
9 import com.xly.service.FormResolverService; 9 import com.xly.service.FormResolverService;
10 import com.xly.service.LedgerService; 10 import com.xly.service.LedgerService;
11 import com.xly.service.OpService; 11 import com.xly.service.OpService;
  12 +import com.xly.service.SkillService;
12 import com.xly.service.SystemPromptService; 13 import com.xly.service.SystemPromptService;
13 import com.xly.tool.ErpReadTool; 14 import com.xly.tool.ErpReadTool;
14 import com.xly.tool.FormCollectTool; 15 import com.xly.tool.FormCollectTool;
15 import com.xly.tool.InteractionTool; 16 import com.xly.tool.InteractionTool;
16 import com.xly.tool.KgQueryTool; 17 import com.xly.tool.KgQueryTool;
17 import com.xly.tool.ProposeWriteTool; 18 import com.xly.tool.ProposeWriteTool;
  19 +import com.xly.tool.UseSkillTool;
18 import dev.langchain4j.model.chat.StreamingChatModel; 20 import dev.langchain4j.model.chat.StreamingChatModel;
19 import dev.langchain4j.service.AiServices; 21 import dev.langchain4j.service.AiServices;
20 import org.springframework.beans.factory.annotation.Qualifier; 22 import org.springframework.beans.factory.annotation.Qualifier;
@@ -48,12 +50,14 @@ public class AgentFactory { @@ -48,12 +50,14 @@ public class AgentFactory {
48 50
49 private final KgQueryTool kgQueryTool; 51 private final KgQueryTool kgQueryTool;
50 private final InteractionTool interactionTool; 52 private final InteractionTool interactionTool;
  53 + private final SkillService skillService;
51 54
52 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, 55 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
53 LedgerService ledger, EventProjectionService projection, 56 LedgerService ledger, EventProjectionService projection,
54 SystemPromptService systemPromptService, 57 SystemPromptService systemPromptService,
55 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, 58 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
56 - ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) { 59 + ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool,
  60 + SkillService skillService) {
57 this.streamingModel = streamingModel; 61 this.streamingModel = streamingModel;
58 this.ledger = ledger; 62 this.ledger = ledger;
59 this.projection = projection; 63 this.projection = projection;
@@ -65,21 +69,20 @@ public class AgentFactory { @@ -65,21 +69,20 @@ public class AgentFactory {
65 this.mapper = mapper; 69 this.mapper = mapper;
66 this.kgQueryTool = kgQueryTool; 70 this.kgQueryTool = kgQueryTool;
67 this.interactionTool = interactionTool; 71 this.interactionTool = interactionTool;
68 - }  
69 -  
70 - public ReActAgent build(AgentIdentity identity) {  
71 - return build(identity, false); 72 + this.skillService = skillService;
72 } 73 }
73 74
74 /** 75 /**
75 - * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations}  
76 - * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 76 + * 组装 ReAct agent:固定 7 工具(useSkill + 6)+ 唯一 system prompt。
  77 + * {@code maxSequentialToolsInvocations} 作为循环护栏,杜绝失控 ReAct 循环。
77 * 78 *
  79 + * @param convId 会话 id(useSkill 落 skill_active 事件需要)
78 * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记, 80 * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记,
79 * 前端历史不显示 81 * 前端历史不显示
80 */ 82 */
81 - public ReActAgent build(AgentIdentity identity, boolean internalUserTurn) { 83 + public ReActAgent build(AgentIdentity identity, String convId, boolean internalUserTurn) {
82 Object[] tools = new Object[]{kgQueryTool, interactionTool, 84 Object[] tools = new Object[]{kgQueryTool, interactionTool,
  85 + new UseSkillTool(skillService, ledger, convId),
83 new ErpReadTool(erp, resolver, identity), 86 new ErpReadTool(erp, resolver, identity),
84 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), 87 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
85 new FormCollectTool(jdbc, resolver, identity, mapper)}; 88 new FormCollectTool(jdbc, resolver, identity, mapper)};
@@ -95,12 +98,7 @@ public class AgentFactory { @@ -95,12 +98,7 @@ public class AgentFactory {
95 .build(); 98 .build();
96 } 99 }
97 100
98 - /** 供确定性「新增」路径直接构建 collectForm 表单(不经 LLM 工具选择)。 */  
99 - public FormCollectTool formCollectTool(AgentIdentity identity) {  
100 - return new FormCollectTool(jdbc, resolver, identity, mapper);  
101 - }  
102 -  
103 - /** 供确定性「修改/作废/审核…」路径直接调 proposeWrite(不经 LLM 工具选择)。 */ 101 + /** 供确定性「表单提交」端点直接调 proposeWrite(action=create)(按钮路径,不经 LLM)。 */
104 public ProposeWriteTool proposeWriteTool(AgentIdentity identity) { 102 public ProposeWriteTool proposeWriteTool(AgentIdentity identity) {
105 return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver); 103 return new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver);
106 } 104 }
src/main/java/com/xly/service/ConversationService.java
@@ -30,17 +30,15 @@ public class ConversationService { @@ -30,17 +30,15 @@ public class ConversationService {
30 private final RedisChatMemoryStore memoryStore; 30 private final RedisChatMemoryStore memoryStore;
31 private final ObjectMapper mapper; 31 private final ObjectMapper mapper;
32 private final LedgerService ledger; 32 private final LedgerService ledger;
33 - private final StateService state;  
34 private final EventProjectionService projection; 33 private final EventProjectionService projection;
35 34
36 public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, 35 public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore,
37 - ObjectMapper mapper, LedgerService ledger, StateService state, 36 + ObjectMapper mapper, LedgerService ledger,
38 EventProjectionService projection) { 37 EventProjectionService projection) {
39 this.redis = redis; 38 this.redis = redis;
40 this.memoryStore = memoryStore; 39 this.memoryStore = memoryStore;
41 this.mapper = mapper; 40 this.mapper = mapper;
42 this.ledger = ledger; 41 this.ledger = ledger;
43 - this.state = state;  
44 this.projection = projection; 42 this.projection = projection;
45 } 43 }
46 44
@@ -137,7 +135,7 @@ public class ConversationService { @@ -137,7 +135,7 @@ public class ConversationService {
137 redis.opsForHash().delete(CONVS_KEY + userId, convId); 135 redis.opsForHash().delete(CONVS_KEY + userId, convId);
138 memoryStore.deleteMessages(convId); 136 memoryStore.deleteMessages(convId);
139 ledger.delete(convId); 137 ledger.delete(convId);
140 - state.delete(convId); 138 + // 旧 chat:state 键随 30 天 TTL 自然过期(StateService 已随意图门废除)
141 } 139 }
142 140
143 /** 141 /**
src/main/java/com/xly/service/EventProjectionService.java
@@ -189,7 +189,11 @@ public class EventProjectionService { @@ -189,7 +189,11 @@ public class EventProjectionService {
189 switch (type) { 189 switch (type) {
190 case "skill_active" -> skillText = str(ev.get("text")); 190 case "skill_active" -> skillText = str(ev.get("text"));
191 case "skill_done" -> skillText = null; 191 case "skill_done" -> skillText = null;
192 - case "form", "form_submit", "proposal", "confirm", "cancel" -> lastFlow = ev; 192 + case "form", "form_submit", "proposal" -> lastFlow = ev;
  193 + case "confirm", "cancel" -> { // 流程终结:单据线与技能线一起摘下
  194 + lastFlow = ev;
  195 + skillText = null;
  196 + }
193 case "tool_result" -> { 197 case "tool_result" -> {
194 String name = str(ev.get("name")); 198 String name = str(ev.get("name"));
195 if ("collectForm".equals(name) || "proposeWrite".equals(name)) { 199 if ("collectForm".equals(name) || "proposeWrite".equals(name)) {
src/main/java/com/xly/service/IntentService.java deleted
1 -package com.xly.service;  
2 -  
3 -import com.fasterxml.jackson.databind.JsonNode;  
4 -import com.xly.agent.Intent;  
5 -import org.slf4j.Logger;  
6 -import org.slf4j.LoggerFactory;  
7 -import org.springframework.stereotype.Service;  
8 -  
9 -import java.util.LinkedHashMap;  
10 -import java.util.List;  
11 -import java.util.Map;  
12 -  
13 -/**  
14 - * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图(4 类), 单据类型, 带角色的实体, 缺失信息}。  
15 - *  
16 - * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。提示只给**类定义**  
17 - * (含有界的行业常识,如 定制品问价=新建报价),不写逐案纠错规则——错误种类无穷,  
18 - * 按案例打补丁写不完;观察到的错例进回归基准(bench50)而不是提示。  
19 - * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。  
20 - *  
21 - * <p>失败(模型不可达/JSON 异常)时返回 intent=其他 的空结果,调用方降级到通用 agent,绝不中断。  
22 - */  
23 -@Service  
24 -public class IntentService {  
25 -  
26 - private static final Logger log = LoggerFactory.getLogger(IntentService.class);  
27 -  
28 - private static final String SYSTEM =  
29 - "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n"  
30 - + "意图定义:\n"  
31 - + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n"  
32 - + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)"  
33 - + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n"  
34 - + "- 操作已有单据:对系统里已存在的单据/记录做修改、作废/删除、审核/反审核、复原。"  
35 - + "只要动作属于这些,即使还没说具体哪条记录也算本类(缺的信息之后澄清)。\n"  
36 - + "- 其他:闲聊、问候、与 ERP 业务无关,或只说名词、没有任何动作、无法判断想做什么。\n"  
37 - + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n"  
38 - + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价),"  
39 - + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。"  
40 - + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。";  
41 -  
42 - private final LlmJsonClient llm;  
43 -  
44 - public IntentService(LlmJsonClient llm) {  
45 - this.llm = llm;  
46 - }  
47 -  
48 - /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */  
49 - public Intent classify(String utterance) {  
50 - return classify(utterance, null);  
51 - }  
52 -  
53 - /** 带会话状态摘要的分类:状态槽让「那张单子」这类跨轮指代可解。 */  
54 - public Intent classify(String utterance, String stateDigest) {  
55 - Intent out = new Intent();  
56 - if (utterance == null || utterance.isBlank()) {  
57 - return out;  
58 - }  
59 - JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema());  
60 - if (n == null) {  
61 - log.warn("intent classify fell back to 其他 (model unavailable)");  
62 - out.failed = true; // 门失败 ≠ 真实闲聊:下游护栏不能因此关闭  
63 - return out;  
64 - }  
65 - String intent = n.path("intent").asText(Intent.OTHER);  
66 - out.intent = normalizeIntent(intent);  
67 - out.danju = n.path("danju").asText("");  
68 - JsonNode es = n.path("entities");  
69 - if (es.isArray()) {  
70 - for (JsonNode e : es) {  
71 - String v = e.path("value").asText("");  
72 - String r = e.path("role").asText("未知");  
73 - if (!v.isBlank()) {  
74 - out.entities.add(new Intent.Entity(v, r));  
75 - }  
76 - }  
77 - }  
78 - JsonNode ms = n.path("missing");  
79 - if (ms.isArray()) {  
80 - for (JsonNode m : ms) {  
81 - String v = m.asText("");  
82 - if (!v.isBlank()) out.missing.add(v);  
83 - }  
84 - }  
85 - return out;  
86 - }  
87 -  
88 - private static final String WRITE_SYSTEM =  
89 - "你是 ERP 写操作的槽位抽取器。从用户话中抽取要操作的记录及改动:"  
90 - + "record=要操作的那条记录的名称(公司名/客户名/物料名/单号等);"  
91 - + "entityType=该记录属于哪类实体(客户/供应商/物料/产品/单据;拿不准就填 客户);"  
92 - + "field=要修改的字段中文名(如 电话/地址/简称/备注),删除或审核时留空;"  
93 - + "newValue=改成什么新值,删除或审核时留空。只输出 JSON,不要解释。";  
94 -  
95 - /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */  
96 - public Intent.WriteSlots extractWrite(String utterance) {  
97 - return extractWrite(utterance, null);  
98 - }  
99 -  
100 - /** 带会话状态摘要的写槽位抽取(「把那张报价单作废」的 record 可从状态里补齐)。 */  
101 - public Intent.WriteSlots extractWrite(String utterance, String stateDigest) {  
102 - Intent.WriteSlots w = new Intent.WriteSlots();  
103 - if (utterance == null || utterance.isBlank()) {  
104 - return w;  
105 - }  
106 - JsonNode n = llm.completeJson(WRITE_SYSTEM, withState(utterance, stateDigest), writeSchema());  
107 - if (n == null) {  
108 - return w;  
109 - }  
110 - w.entityType = n.path("entityType").asText("");  
111 - w.record = n.path("record").asText("");  
112 - w.field = n.path("field").asText("");  
113 - w.newValue = n.path("newValue").asText("");  
114 - return w;  
115 - }  
116 -  
117 - private Map<String, Object> writeSchema() {  
118 - Map<String, Object> props = new LinkedHashMap<>();  
119 - props.put("entityType", Map.of("type", "string"));  
120 - props.put("record", Map.of("type", "string"));  
121 - props.put("field", Map.of("type", "string"));  
122 - props.put("newValue", Map.of("type", "string"));  
123 - Map<String, Object> schema = new LinkedHashMap<>();  
124 - schema.put("type", "object");  
125 - schema.put("properties", props);  
126 - schema.put("required", List.of("record"));  
127 - return schema;  
128 - }  
129 -  
130 - /** 状态摘要作为输入前缀(有状态才加,单句冷启动时输入形态不变)。 */  
131 - private static String withState(String utterance, String stateDigest) {  
132 - String u = utterance.trim();  
133 - if (stateDigest == null || stateDigest.isBlank()) {  
134 - return u;  
135 - }  
136 - return "【会话状态】" + stateDigest + "\n【这句话】" + u;  
137 - }  
138 -  
139 - private static String normalizeIntent(String s) {  
140 - if (s == null) return Intent.OTHER;  
141 - s = s.trim();  
142 - switch (s) {  
143 - case Intent.QUERY:  
144 - case Intent.CREATE:  
145 - case Intent.OPERATE:  
146 - case Intent.OTHER:  
147 - return s;  
148 - default:  
149 - return Intent.OTHER;  
150 - }  
151 - }  
152 -  
153 - private Map<String, Object> schema() {  
154 - Map<String, Object> entityProps = new LinkedHashMap<>();  
155 - entityProps.put("value", Map.of("type", "string"));  
156 - entityProps.put("role", Map.of("type", "string",  
157 - "enum", List.of("客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知")));  
158 - Map<String, Object> entityItem = new LinkedHashMap<>();  
159 - entityItem.put("type", "object");  
160 - entityItem.put("properties", entityProps);  
161 - entityItem.put("required", List.of("value", "role"));  
162 -  
163 - Map<String, Object> props = new LinkedHashMap<>();  
164 - props.put("intent", Map.of("type", "string",  
165 - "enum", List.of("查询", "新增", "操作已有单据", "其他")));  
166 - props.put("danju", Map.of("type", "string"));  
167 - props.put("entities", Map.of("type", "array", "items", entityItem));  
168 - props.put("missing", Map.of("type", "array", "items", Map.of("type", "string")));  
169 -  
170 - Map<String, Object> schema = new LinkedHashMap<>();  
171 - schema.put("type", "object");  
172 - schema.put("properties", props);  
173 - schema.put("required", List.of("intent", "danju", "entities"));  
174 - return schema;  
175 - }  
176 -}  
src/main/java/com/xly/service/LlmJsonClient.java deleted
1 -package com.xly.service;  
2 -  
3 -import com.fasterxml.jackson.databind.JsonNode;  
4 -import com.fasterxml.jackson.databind.ObjectMapper;  
5 -import com.xly.config.TracingChatModelListener;  
6 -import com.xly.util.OkHttpUtil;  
7 -import org.slf4j.Logger;  
8 -import org.slf4j.LoggerFactory;  
9 -import org.springframework.beans.factory.annotation.Value;  
10 -import org.springframework.stereotype.Service;  
11 -  
12 -import java.time.Instant;  
13 -import java.util.LinkedHashMap;  
14 -import java.util.List;  
15 -import java.util.Map;  
16 -  
17 -/**  
18 - * OpenAI 兼容协议(/v1/chat/completions)的**受约束 JSON**补全(constrained decoding)。  
19 - *  
20 - * <p>用 {@code response_format={type:"json_schema", json_schema:{schema}}} 做语法约束解码,  
21 - * 保证输出**一定**是 schema 合法的 JSON(Ollama 侧由 XGrammar 实现,违反 schema 的 token 概率直接置 0)——  
22 - * 这是根治「把产品名塞进客户字段」这类**槽位/参数幻觉**的关键手段。实测 qwen3:14b 在此模式下  
23 - * 意图/实体抽取 8/8 正确、~2-3s/次(reasoning_effort=none)。  
24 - *  
25 - * <p>用于两处「窄而稳」的推理子任务:{@link IntentService}(意图+实体分类)与受约束槽位填充。  
26 - * 主对话/查询走 LangChain4j 工具循环。  
27 - */  
28 -@Service  
29 -public class LlmJsonClient {  
30 -  
31 - private static final Logger log = LoggerFactory.getLogger(LlmJsonClient.class);  
32 -  
33 - private final ObjectMapper mapper;  
34 - private final TracingChatModelListener tracing;  
35 - private final OkHttpUtil http = OkHttpUtil.getInstance(10, 120, 30);  
36 -  
37 - @Value("${llm.base-url}")  
38 - private String baseUrl;  
39 -  
40 - @Value("${llm.api-key:ollama}")  
41 - private String apiKey;  
42 -  
43 - @Value("${llm.chat-model}")  
44 - private String model;  
45 -  
46 - public LlmJsonClient(ObjectMapper mapper, TracingChatModelListener tracing) {  
47 - this.mapper = mapper;  
48 - this.tracing = tracing;  
49 - }  
50 -  
51 - /**  
52 - * 受约束 JSON 补全:低温度、response_format=json_schema、非流式。  
53 - *  
54 - * @param system 系统提示(角色 + 抽取规则)  
55 - * @param user 用户话  
56 - * @param schema JSON Schema(Map 结构,序列化进 response_format.json_schema.schema)  
57 - * @return 解析后的 JsonNode;失败返回 null(调用方须降级处理,绝不因它中断主流程)  
58 - */  
59 - public JsonNode completeJson(String system, String user, Map<String, Object> schema) {  
60 - long t0 = System.nanoTime();  
61 - String startTs = Instant.now().toString();  
62 - try {  
63 - Map<String, Object> body = new LinkedHashMap<>();  
64 - body.put("model", model);  
65 - body.put("stream", false);  
66 - body.put("temperature", 0.1);  
67 - body.put("top_p", 0.9);  
68 - body.put("reasoning_effort", "none");  
69 - body.put("response_format", Map.of(  
70 - "type", "json_schema",  
71 - "json_schema", Map.of("name", "output", "schema", schema)));  
72 - body.put("messages", List.of(  
73 - Map.of("role", "system", "content", system),  
74 - Map.of("role", "user", "content", user)));  
75 -  
76 - String json = mapper.writeValueAsString(body);  
77 - String resp = http.postJson(baseUrl + "/chat/completions", apiKey, json);  
78 - JsonNode root = mapper.readTree(resp);  
79 - JsonNode usage = root.path("usage");  
80 - tracing.record(model, t0, startTs,  
81 - usage.path("prompt_tokens").isNumber() ? usage.path("prompt_tokens").asInt() : null,  
82 - usage.path("completion_tokens").isNumber() ? usage.path("completion_tokens").asInt() : null,  
83 - null);  
84 - String content = root.path("choices").path(0).path("message").path("content").asText("");  
85 - if (content.isBlank()) {  
86 - log.warn("llm json completion: empty content");  
87 - return null;  
88 - }  
89 - return mapper.readTree(content);  
90 - } catch (Exception e) {  
91 - tracing.record(model, t0, startTs, null, null, e.getMessage());  
92 - return null;  
93 - }  
94 - }  
95 -}  
src/main/java/com/xly/service/SkillService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.slf4j.Logger;
  4 +import org.slf4j.LoggerFactory;
  5 +import org.springframework.beans.factory.annotation.Value;
  6 +import org.springframework.core.io.Resource;
  7 +import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
  8 +import org.springframework.stereotype.Service;
  9 +
  10 +import java.io.IOException;
  11 +import java.nio.charset.StandardCharsets;
  12 +import java.nio.file.Files;
  13 +import java.nio.file.Path;
  14 +import java.util.ArrayList;
  15 +import java.util.List;
  16 +import java.util.stream.Stream;
  17 +
  18 +/**
  19 + * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。
  20 + *
  21 + * <p>文件格式:{@code <名称>.md},首行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。
  22 + * 默认从 classpath {@code skills/*.md} 读取(随包发布、缓存一次);配置
  23 + * {@code xly.skills.dir} 指向文件系统目录后改从该目录**每次现读**——改文件即热更,不用重启。
  24 + */
  25 +@Service
  26 +public class SkillService {
  27 +
  28 + private static final Logger log = LoggerFactory.getLogger(SkillService.class);
  29 +
  30 + public record Skill(String name, String brief, String body) { }
  31 +
  32 + @Value("${xly.skills.dir:}")
  33 + private String skillsDir;
  34 +
  35 + private volatile List<Skill> classpathCache;
  36 +
  37 + public List<Skill> all() {
  38 + if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) {
  39 + return loadFromDir(Path.of(skillsDir));
  40 + }
  41 + List<Skill> c = classpathCache;
  42 + if (c == null) {
  43 + c = loadFromClasspath();
  44 + classpathCache = c;
  45 + }
  46 + return c;
  47 + }
  48 +
  49 + /** 按名精确匹配,其次互相包含(「新建报价单」→「新建报价」)。找不到返回 null。 */
  50 + public Skill find(String name) {
  51 + if (name == null || name.isBlank()) {
  52 + return null;
  53 + }
  54 + String n = name.trim();
  55 + List<Skill> skills = all();
  56 + for (Skill s : skills) {
  57 + if (s.name().equals(n)) {
  58 + return s;
  59 + }
  60 + }
  61 + for (Skill s : skills) {
  62 + if (n.contains(s.name()) || s.name().contains(n)) {
  63 + return s;
  64 + }
  65 + }
  66 + return null;
  67 + }
  68 +
  69 + /** system prompt 的技能索引(一行一个:名称:用途)。 */
  70 + public String indexLines() {
  71 + StringBuilder sb = new StringBuilder();
  72 + for (Skill s : all()) {
  73 + sb.append("- ").append(s.name()).append(":").append(s.brief).append('\n');
  74 + }
  75 + return sb.toString();
  76 + }
  77 +
  78 + private List<Skill> loadFromDir(Path dir) {
  79 + List<Skill> out = new ArrayList<>();
  80 + try (Stream<Path> files = Files.list(dir)) {
  81 + files.filter(p -> p.getFileName().toString().endsWith(".md")).sorted().forEach(p -> {
  82 + try {
  83 + Skill s = parse(p.getFileName().toString(), Files.readString(p, StandardCharsets.UTF_8));
  84 + if (s != null) {
  85 + out.add(s);
  86 + }
  87 + } catch (IOException e) {
  88 + log.warn("skill 读取失败 {}: {}", p, e.getMessage());
  89 + }
  90 + });
  91 + } catch (IOException e) {
  92 + log.warn("skill 目录读取失败 {}: {}", dir, e.getMessage());
  93 + }
  94 + return out;
  95 + }
  96 +
  97 + private List<Skill> loadFromClasspath() {
  98 + List<Skill> out = new ArrayList<>();
  99 + try {
  100 + Resource[] resources = new PathMatchingResourcePatternResolver()
  101 + .getResources("classpath:skills/*.md");
  102 + for (Resource r : resources) {
  103 + try {
  104 + Skill s = parse(r.getFilename(), new String(
  105 + r.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
  106 + if (s != null) {
  107 + out.add(s);
  108 + }
  109 + } catch (IOException e) {
  110 + log.warn("skill 读取失败 {}: {}", r.getFilename(), e.getMessage());
  111 + }
  112 + }
  113 + } catch (IOException e) {
  114 + log.warn("classpath skills 加载失败: {}", e.getMessage());
  115 + }
  116 + out.sort((a, b) -> a.name().compareTo(b.name()));
  117 + return out;
  118 + }
  119 +
  120 + private static Skill parse(String filename, String content) {
  121 + if (filename == null || content == null || content.isBlank()) {
  122 + return null;
  123 + }
  124 + String name = filename.endsWith(".md") ? filename.substring(0, filename.length() - 3) : filename;
  125 + int nl = content.indexOf('\n');
  126 + String brief = (nl < 0 ? content : content.substring(0, nl)).trim();
  127 + String body = nl < 0 ? "" : content.substring(nl + 1).trim();
  128 + return new Skill(name, brief, body);
  129 + }
  130 +}
src/main/java/com/xly/service/SlotFillService.java deleted
1 -package com.xly.service;  
2 -  
3 -import com.fasterxml.jackson.databind.ObjectMapper;  
4 -import com.xly.agent.Intent;  
5 -import org.springframework.stereotype.Service;  
6 -  
7 -import java.util.LinkedHashMap;  
8 -import java.util.List;  
9 -import java.util.Map;  
10 -import java.util.regex.Matcher;  
11 -import java.util.regex.Pattern;  
12 -  
13 -/**  
14 - * 新增单据的**确定性槽位映射**:把意图门已抽好的「带角色实体」+ 正则解析的尺寸/数量,  
15 - * 按表单真实字段落位,产出 collectForm 的预填 JSON。  
16 - *  
17 - * <p>为什么不用 LLM 做这步:实测 qwen3:14b 能可靠区分「产品 vs 客户」(意图门已给出角色),  
18 - * 但对「长和宽50cm,高5cm」这类**尺寸拆分**始终出错(塞进备注/单价)。这类窄任务用 Java 正则 +  
19 - * 角色映射远比让模型受约束填槽可靠——把不确定的判断交给能确定处理的代码,是治『不智能』的关键手法之一。  
20 - */  
21 -@Service  
22 -public class SlotFillService {  
23 -  
24 - private final ObjectMapper mapper;  
25 -  
26 - public SlotFillService(ObjectMapper mapper) {  
27 - this.mapper = mapper;  
28 - }  
29 -  
30 - // 标注式尺寸:"长和宽50"、"高5cm"、"长:50"  
31 - private static final Pattern DIM_LABELED =  
32 - Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)");  
33 - // 位置式尺寸:"50*30*5"、"50x30x5"、"50×30×5"  
34 - private static final Pattern DIM_POS =  
35 - Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");  
36 -  
37 - /**  
38 - * 依据意图门的角色实体 + 正则,确定性地构建新增表单的预填字段(中文字段名 -> 值)。  
39 - *  
40 - * @param utterance 用户原话(用于解析尺寸)  
41 - * @param entity 单据/实体类型(如 报价 / 客户)  
42 - * @param fields {@code FormResolverService.businessFields}(含 label/fk/type)  
43 - * @param it 意图门结果(提供带角色的实体)  
44 - */  
45 - public Map<String, String> buildCreateFields(String utterance, String entity,  
46 - List<Map<String, Object>> fields, Intent it) {  
47 - Map<String, String> known = new LinkedHashMap<>();  
48 - if (fields == null || fields.isEmpty()) {  
49 - return known;  
50 - }  
51 - // 干净字段名 -> 原始 label(供尺寸/数量按名定位)  
52 - Map<String, String> cleanToLabel = new LinkedHashMap<>();  
53 - for (Map<String, Object> f : fields) {  
54 - String label = str(f.get("label"));  
55 - if (label != null && !label.isBlank()) {  
56 - cleanToLabel.putIfAbsent(cleanLabel(label), label);  
57 - }  
58 - }  
59 -  
60 - String cust = it == null ? null : it.firstValueByRole("客户");  
61 - String prod = it == null ? null : it.firstValueByRole("产品");  
62 - String mat = it == null ? null : it.firstValueByRole("物料");  
63 - String sup = it == null ? null : it.firstValueByRole("供应商");  
64 -  
65 - // 1) 外键角色字段:客户/产品/物料/供应商 → 对应 FK 字段  
66 - for (Map<String, Object> f : fields) {  
67 - String fk = str(f.get("fk"));  
68 - String label = str(f.get("label"));  
69 - if (fk == null || label == null) continue;  
70 - if ("elecustomer".equals(fk) && cust != null) known.putIfAbsent(label, cust);  
71 - else if ("eleproduct".equals(fk) && prod != null) known.putIfAbsent(label, prod);  
72 - else if ("elematerials".equals(fk) && mat != null) known.putIfAbsent(label, mat);  
73 - else if ("elesupplier".equals(fk) && sup != null) known.putIfAbsent(label, sup);  
74 - }  
75 -  
76 - // 2) 数量:意图门角色=数量 → 只留数字  
77 - String qty = it == null ? null : it.firstValueByRole("数量");  
78 - if (qty != null) {  
79 - String lbl = cleanToLabel.get("数量");  
80 - if (lbl != null) {  
81 - String d = qty.replaceAll("[^0-9.]", "");  
82 - if (!d.isEmpty()) known.putIfAbsent(lbl, d);  
83 - }  
84 - }  
85 -  
86 - // 3) 尺寸:正则从原话解析 长/宽/高  
87 - Map<String, String> dims = parseDimensions(utterance);  
88 - for (Map.Entry<String, String> e : dims.entrySet()) {  
89 - String lbl = cleanToLabel.get(e.getKey()); // "长"/"宽"/"高"  
90 - if (lbl != null) known.putIfAbsent(lbl, e.getValue());  
91 - }  
92 -  
93 - // 4) 名称字段:新增某实体本身(如"新增客户叫X")时,实体名要落到它的名称字段  
94 - // (客户表本身没有 elecustomer 外键列,故不会被上面的 FK 环填上,需在此补)。  
95 - String nameLabel = findNameField(fields);  
96 - if (nameLabel != null && !known.containsKey(nameLabel)) {  
97 - String selfRole = danjuToRole(entity);  
98 - String nameVal = (selfRole != null && it != null) ? it.firstValueByRole(selfRole) : null;  
99 - if (nameVal == null && cust == null && prod == null && mat == null && sup == null) {  
100 - nameVal = firstEntityValue(it); // 兜底:单据类型不含角色词时用首个实体  
101 - }  
102 - if (nameVal != null) {  
103 - known.put(nameLabel, nameVal);  
104 - }  
105 - }  
106 - return known;  
107 - }  
108 -  
109 - /** 单据/实体类型 → 它自身的业务角色(新增该实体时,该角色的实体值即其名称)。 */  
110 - private static String danjuToRole(String entity) {  
111 - if (entity == null) return null;  
112 - if (entity.contains("客户")) return "客户";  
113 - if (entity.contains("供应商")) return "供应商";  
114 - if (entity.contains("物料")) return "物料";  
115 - if (entity.contains("产品")) return "产品";  
116 - return null;  
117 - }  
118 -  
119 - /** 解析尺寸 → {长,宽,高} 的数字(cm/mm 单位忽略,只取数值)。 */  
120 - public Map<String, String> parseDimensions(String utterance) {  
121 - Map<String, String> out = new LinkedHashMap<>();  
122 - if (utterance == null || utterance.isBlank()) return out;  
123 - Matcher m = DIM_LABELED.matcher(utterance);  
124 - boolean any = false;  
125 - while (m.find()) {  
126 - String labels = m.group(1);  
127 - String val = m.group(2);  
128 - for (int i = 0; i < labels.length(); i++) {  
129 - char c = labels.charAt(i);  
130 - String key = dimKey(c);  
131 - if (key != null) {  
132 - out.put(key, val);  
133 - any = true;  
134 - }  
135 - }  
136 - }  
137 - if (!any) {  
138 - Matcher p = DIM_POS.matcher(utterance);  
139 - if (p.find()) {  
140 - out.put("长", p.group(1));  
141 - out.put("宽", p.group(2));  
142 - if (p.group(3) != null) out.put("高", p.group(3));  
143 - }  
144 - }  
145 - return out;  
146 - }  
147 -  
148 - private static String dimKey(char c) {  
149 - switch (c) {  
150 - case '长': return "长";  
151 - case '宽': return "宽";  
152 - case '高':  
153 - case '厚':  
154 - case '深': return "高";  
155 - default: return null;  
156 - }  
157 - }  
158 -  
159 - /** 名称字段:优先 label 以「名称」结尾且非外键的字段;否则第一个 label 含「名称」的。 */  
160 - private static String findNameField(List<Map<String, Object>> fields) {  
161 - for (Map<String, Object> f : fields) {  
162 - String label = str(f.get("label"));  
163 - String fk = str(f.get("fk"));  
164 - if (label != null && label.endsWith("名称") && (fk == null || fk.isBlank())) {  
165 - return label;  
166 - }  
167 - }  
168 - for (Map<String, Object> f : fields) {  
169 - String label = str(f.get("label"));  
170 - if (label != null && label.contains("名称")) return label;  
171 - }  
172 - return null;  
173 - }  
174 -  
175 - private static String firstEntityValue(Intent it) {  
176 - if (it == null) return null;  
177 - for (Intent.Entity e : it.entities) {  
178 - if (e != null && e.value != null && !e.value.isBlank()) return e.value.trim();  
179 - }  
180 - return null;  
181 - }  
182 -  
183 - /** 序列化为 knownFieldsJson,供 {@code FormCollectTool.collectForm} 预填。 */  
184 - public String toJson(Map<String, String> known) {  
185 - try {  
186 - return mapper.writeValueAsString(known);  
187 - } catch (Exception e) {  
188 - return "{}";  
189 - }  
190 - }  
191 -  
192 - private static String cleanLabel(String s) {  
193 - if (s == null) return "";  
194 - return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();  
195 - }  
196 -  
197 - private static String str(Object o) {  
198 - return o == null ? null : o.toString();  
199 - }  
200 -}  
src/main/java/com/xly/service/StateService.java deleted
1 -package com.xly.service;  
2 -  
3 -import com.fasterxml.jackson.databind.JsonNode;  
4 -import com.fasterxml.jackson.databind.ObjectMapper;  
5 -import com.fasterxml.jackson.databind.node.ArrayNode;  
6 -import com.fasterxml.jackson.databind.node.ObjectNode;  
7 -import com.xly.agent.Intent;  
8 -import org.slf4j.Logger;  
9 -import org.slf4j.LoggerFactory;  
10 -import org.springframework.data.redis.core.StringRedisTemplate;  
11 -import org.springframework.stereotype.Service;  
12 -  
13 -import java.time.Duration;  
14 -import java.util.List;  
15 -  
16 -/**  
17 - * 会话状态槽 —— 由**代码**(而非模型总结)维护的少量结构化状态:上轮意图 / 最近实体 / 在办单据。  
18 - * 注入两处:意图门的输入前缀(让「那张单子」这类指代可解),agent 用户消息尾部(稳住多轮上下文)。  
19 - *  
20 - * <p>键:Redis HASH {@code chat:state:{convId}}(fields: intent/danju/entities/doc),30 天 TTL。  
21 - */  
22 -@Service  
23 -public class StateService {  
24 -  
25 - private static final Logger log = LoggerFactory.getLogger(StateService.class);  
26 - private static final String PREFIX = "chat:state:";  
27 - private static final Duration TTL = Duration.ofDays(30);  
28 - private static final int MAX_ENTITIES = 8;  
29 -  
30 - private final StringRedisTemplate redis;  
31 - private final ObjectMapper mapper;  
32 -  
33 - public StateService(StringRedisTemplate redis, ObjectMapper mapper) {  
34 - this.redis = redis;  
35 - this.mapper = mapper;  
36 - }  
37 -  
38 - /** 记录本轮意图门结果(上轮意图 + 单据类型)。 */  
39 - public void recordIntent(String convId, String intent, String danju) {  
40 - try {  
41 - String key = PREFIX + convId;  
42 - redis.opsForHash().put(key, "intent", intent == null ? "" : intent);  
43 - redis.opsForHash().put(key, "danju", danju == null ? "" : danju);  
44 - redis.expire(key, TTL);  
45 - } catch (Exception e) {  
46 - log.warn("state recordIntent failed (conv={}): {}", convId, e.getMessage());  
47 - }  
48 - }  
49 -  
50 - /** 合并本轮识别到的实体(最新在前、按 值+角色 去重、封顶 {@value #MAX_ENTITIES} 个)。 */  
51 - public void mergeEntities(String convId, List<Intent.Entity> entities) {  
52 - if (entities == null || entities.isEmpty()) {  
53 - return;  
54 - }  
55 - try {  
56 - String key = PREFIX + convId;  
57 - ArrayNode merged = mapper.createArrayNode();  
58 - for (Intent.Entity e : entities) {  
59 - if (e == null || e.value == null || e.value.isBlank()) continue;  
60 - ObjectNode n = merged.addObject();  
61 - n.put("value", e.value.trim());  
62 - n.put("role", e.role == null ? "未知" : e.role);  
63 - }  
64 - Object old = redis.opsForHash().get(key, "entities");  
65 - if (old != null) {  
66 - JsonNode arr = mapper.readTree(old.toString());  
67 - for (JsonNode n : arr) {  
68 - if (merged.size() >= MAX_ENTITIES) break;  
69 - boolean dup = false;  
70 - for (JsonNode m : merged) {  
71 - if (m.path("value").asText().equals(n.path("value").asText())  
72 - && m.path("role").asText().equals(n.path("role").asText())) {  
73 - dup = true;  
74 - break;  
75 - }  
76 - }  
77 - if (!dup) merged.add(n);  
78 - }  
79 - }  
80 - redis.opsForHash().put(key, "entities", mapper.writeValueAsString(merged));  
81 - redis.expire(key, TTL);  
82 - } catch (Exception e) {  
83 - log.warn("state mergeEntities failed (conv={}): {}", convId, e.getMessage());  
84 - }  
85 - }  
86 -  
87 - /** 设置在办单据(entity=单据/实体类型,record=记录名/单号,stage=collecting|proposed|executed|failed|cancelled)。 */  
88 - public void setActiveDoc(String convId, String entity, String record, String opId, String stage) {  
89 - try {  
90 - String key = PREFIX + convId;  
91 - ObjectNode doc = mapper.createObjectNode();  
92 - doc.put("entity", entity == null ? "" : entity);  
93 - doc.put("record", record == null ? "" : record);  
94 - doc.put("opId", opId == null ? "" : opId);  
95 - doc.put("stage", stage == null ? "" : stage);  
96 - redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc));  
97 - redis.expire(key, TTL);  
98 - } catch (Exception e) {  
99 - log.warn("state setActiveDoc failed (conv={}): {}", convId, e.getMessage());  
100 - }  
101 - }  
102 -  
103 - /** 确认/取消后推进在办单据阶段(仅当 opId 匹配当前在办单据)。 */  
104 - public void updateDocStage(String convId, String opId, String stage) {  
105 - if (convId == null || convId.isBlank() || opId == null || opId.isBlank()) {  
106 - return;  
107 - }  
108 - try {  
109 - String key = PREFIX + convId;  
110 - Object old = redis.opsForHash().get(key, "doc");  
111 - if (old == null) {  
112 - return;  
113 - }  
114 - ObjectNode doc = (ObjectNode) mapper.readTree(old.toString());  
115 - if (!opId.equals(doc.path("opId").asText(""))) {  
116 - return;  
117 - }  
118 - doc.put("stage", stage);  
119 - redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc));  
120 - } catch (Exception e) {  
121 - log.warn("state updateDocStage failed (conv={}): {}", convId, e.getMessage());  
122 - }  
123 - }  
124 -  
125 - /** 状态摘要(一行中文),空状态返回 ""。喂意图门 + 附在 agent 用户消息尾部。 */  
126 - public String digest(String convId) {  
127 - try {  
128 - String key = PREFIX + convId;  
129 - Object intent = redis.opsForHash().get(key, "intent");  
130 - Object danju = redis.opsForHash().get(key, "danju");  
131 - Object doc = redis.opsForHash().get(key, "doc");  
132 - Object entities = redis.opsForHash().get(key, "entities");  
133 - StringBuilder sb = new StringBuilder();  
134 - if (intent != null && !intent.toString().isBlank()) {  
135 - sb.append("上轮意图=").append(intent);  
136 - if (danju != null && !danju.toString().isBlank()) {  
137 - sb.append("(").append(danju).append(")");  
138 - }  
139 - }  
140 - if (doc != null) {  
141 - JsonNode d = mapper.readTree(doc.toString());  
142 - String ent = d.path("entity").asText("");  
143 - String rec = d.path("record").asText("");  
144 - String stage = d.path("stage").asText("");  
145 - if (!ent.isBlank() || !rec.isBlank()) {  
146 - if (sb.length() > 0) sb.append(";");  
147 - sb.append("在办单据=").append(ent);  
148 - if (!rec.isBlank()) sb.append("【").append(rec).append("】");  
149 - if (!stage.isBlank()) sb.append("(").append(stageZh(stage)).append(")");  
150 - }  
151 - }  
152 - if (entities != null) {  
153 - JsonNode arr = mapper.readTree(entities.toString());  
154 - StringBuilder es = new StringBuilder();  
155 - for (JsonNode n : arr) {  
156 - if (es.length() > 0) es.append("、");  
157 - es.append(n.path("role").asText("未知")).append("=").append(n.path("value").asText(""));  
158 - }  
159 - if (es.length() > 0) {  
160 - if (sb.length() > 0) sb.append(";");  
161 - sb.append("最近实体=").append(es);  
162 - }  
163 - }  
164 - return sb.toString();  
165 - } catch (Exception e) {  
166 - return "";  
167 - }  
168 - }  
169 -  
170 - private static String stageZh(String stage) {  
171 - switch (stage) {  
172 - case "collecting": return "填表中";  
173 - case "proposed": return "待确认";  
174 - case "executed": return "已执行";  
175 - case "failed": return "执行失败";  
176 - case "cancelled": return "已取消";  
177 - default: return stage;  
178 - }  
179 - }  
180 -  
181 - public void delete(String convId) {  
182 - try {  
183 - redis.delete(PREFIX + convId);  
184 - } catch (Exception ignore) {  
185 - }  
186 - }  
187 -}  
src/main/java/com/xly/service/SystemPromptService.java
1 package com.xly.service; 1 package com.xly.service;
2 2
  3 +import org.springframework.core.io.ClassPathResource;
3 import org.springframework.jdbc.core.JdbcTemplate; 4 import org.springframework.jdbc.core.JdbcTemplate;
4 import org.springframework.stereotype.Service; 5 import org.springframework.stereotype.Service;
5 6
  7 +import java.nio.charset.StandardCharsets;
6 import java.util.List; 8 import java.util.List;
7 import java.util.Map; 9 import java.util.Map;
8 10
9 /** 11 /**
10 - * 构建 agent 的**唯一** system prompt。 12 + * 构建 agent 的**唯一** system prompt:模板 {@code prompts/system.txt} + 业务域地图(DB,缓存一次)
  13 + * + 技能索引({@link SkillService},目录模式下热更)。
11 * 14 *
12 - * <p>所有轮次共用同一份提示 + 同一组 6 个工具(findForms/readFormData/lookupRecord/  
13 - * collectForm/proposeWrite/askUser):n=50 基准显示按意图分版收不到准确率收益,  
14 - * 反而破坏 KV-cache 前缀稳定性。提示遵循弱模型提示工程:关键规则前置、正向表述、  
15 - * 工具清单短;域知识(业务域地图、业务常识)常驻,不做按类分版。 15 + * <p>所有轮次共用同一份提示 + 同一组固定工具(useSkill/findForms/readFormData/lookupRecord/
  16 + * collectForm/proposeWrite/askUser):稳定前缀利于 KV-cache;流程知识在技能文本里,
  17 + * 由模型按需 useSkill 载入,不再压进 prompt。基准脚本(bench/bench_ext.py --arch new)
  18 + * 读取同一模板与技能文件,避免复刻漂移。
16 */ 19 */
17 @Service 20 @Service
18 public class SystemPromptService { 21 public class SystemPromptService {
19 22
20 private final JdbcTemplate jdbc; 23 private final JdbcTemplate jdbc;
  24 + private final SkillService skills;
21 25
22 - private volatile String prompt; 26 + private volatile String template;
  27 + private volatile String domainMap;
23 28
24 - public SystemPromptService(JdbcTemplate jdbc) { 29 + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) {
25 this.jdbc = jdbc; 30 this.jdbc = jdbc;
  31 + this.skills = skills;
26 } 32 }
27 33
28 - private static final String HEADER =  
29 - "【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,"  
30 - + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"  
31 - + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n";  
32 -  
33 public String prompt() { 34 public String prompt() {
34 - String p = prompt;  
35 - if (p == null) {  
36 - p = render();  
37 - prompt = p; 35 + String t = template;
  36 + if (t == null) {
  37 + t = loadTemplate();
  38 + template = t;
  39 + }
  40 + String d = domainMap;
  41 + if (d == null) {
  42 + d = renderDomainMap();
  43 + domainMap = d;
38 } 44 }
39 - return p; 45 + return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", skills.indexLines());
40 } 46 }
41 47
42 - private String render() {  
43 - return HEADER + """  
44 -  
45 - 【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】  
46 - %s  
47 - 【业务常识】  
48 - - 对定制产品问价格(多少钱/什么价/报个价)= 要**新建一张报价单**:价格由系统核价算出,系统里没有现成价。只有给了单号、或明确说查已有报价/统计报价额,才是查询。  
49 - - 业务单据的「删除/取消」= **作废**(proposeWrite action=invalid,可复原),不要物理删除。  
50 -  
51 - 【可用工具(只有这些)】  
52 - - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。  
53 - - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。  
54 - - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。  
55 - - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。  
56 - - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。  
57 - - askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。  
58 -  
59 - 【准则】  
60 - 1. 查询:findForms 定位 → readFormData/lookupRecord 读 → 如实汇报。同名多张表单优先选检索结果靠前那张。**绝不编造**表单名、单号或数据——答案里的每个数字都必须来自工具结果。  
61 - 2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**。新增先 collectForm;proposeWrite 生成提议后就停下、提示用户点【确认】,不要继续调别的工具。  
62 - 3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选,**绝不新建不存在的客户**。  
63 - 4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答,不要反复追问。回答面向业务人员:用记录名称而不是内部 ID。  
64 - """.formatted(renderDomainMap()); 48 + private static String loadTemplate() {
  49 + try {
  50 + return new String(new ClassPathResource("prompts/system.txt")
  51 + .getInputStream().readAllBytes(), StandardCharsets.UTF_8);
  52 + } catch (Exception e) {
  53 + throw new IllegalStateException("system prompt 模板缺失(prompts/system.txt)", e);
  54 + }
65 } 55 }
66 56
67 private String renderDomainMap() { 57 private String renderDomainMap() {
src/main/java/com/xly/tool/FormCollectTool.java
@@ -13,6 +13,8 @@ import java.util.Iterator; @@ -13,6 +13,8 @@ import java.util.Iterator;
13 import java.util.LinkedHashMap; 13 import java.util.LinkedHashMap;
14 import java.util.List; 14 import java.util.List;
15 import java.util.Map; 15 import java.util.Map;
  16 +import java.util.regex.Matcher;
  17 +import java.util.regex.Pattern;
16 18
17 /** 19 /**
18 * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, 20 * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
@@ -33,6 +35,14 @@ public class FormCollectTool { @@ -33,6 +35,14 @@ public class FormCollectTool {
33 35
34 private static final int MAX_FIELDS = 40; 36 private static final int MAX_FIELDS = 40;
35 37
  38 + // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理)
  39 + // 标注式:"长和宽50"、"高5cm"、"长:50"
  40 + private static final Pattern DIM_LABELED =
  41 + Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)");
  42 + // 位置式:"50*30*5"、"50x30x5"、"50×30×5"
  43 + private static final Pattern DIM_POS =
  44 + Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
  45 +
36 private final JdbcTemplate jdbc; 46 private final JdbcTemplate jdbc;
37 private final FormResolverService resolver; 47 private final FormResolverService resolver;
38 private final AgentIdentity identity; 48 private final AgentIdentity identity;
@@ -58,6 +68,7 @@ public class FormCollectTool { @@ -58,6 +68,7 @@ public class FormCollectTool {
58 return err("缺少实体类型。"); 68 return err("缺少实体类型。");
59 } 69 }
60 Map<String, String> known = parseKnown(knownFieldsJson); 70 Map<String, String> known = parseKnown(knownFieldsJson);
  71 + expandDimensions(known);
61 Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); 72 Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
62 if (form == null) { 73 if (form == null) {
63 return err("找不到「" + entityKeyword + "」对应的可新建表单。"); 74 return err("找不到「" + entityKeyword + "」对应的可新建表单。");
@@ -246,6 +257,54 @@ public class FormCollectTool { @@ -246,6 +257,54 @@ public class FormCollectTool {
246 return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); 257 return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim();
247 } 258 }
248 259
  260 + /**
  261 + * 预填值里的尺寸表达(「50*30*5」「长和宽50,高5cm」)确定性拆成 长/宽/高 三个键
  262 + * (模型拆不对是实测结论——原 SlotFillService 的正则迁于此)。已有的 长/宽/高 预填不覆盖。
  263 + */
  264 + static void expandDimensions(Map<String, String> known) {
  265 + Map<String, String> dims = new LinkedHashMap<>();
  266 + for (String v : known.values()) {
  267 + if (v == null || v.isBlank()) {
  268 + continue;
  269 + }
  270 + Matcher m = DIM_LABELED.matcher(v);
  271 + boolean any = false;
  272 + while (m.find()) {
  273 + String labels = m.group(1);
  274 + String val = m.group(2);
  275 + for (int i = 0; i < labels.length(); i++) {
  276 + String key = dimKey(labels.charAt(i));
  277 + if (key != null) {
  278 + dims.putIfAbsent(key, val);
  279 + any = true;
  280 + }
  281 + }
  282 + }
  283 + if (!any) {
  284 + Matcher p = DIM_POS.matcher(v);
  285 + if (p.find()) {
  286 + dims.putIfAbsent("长", p.group(1));
  287 + dims.putIfAbsent("宽", p.group(2));
  288 + if (p.group(3) != null) {
  289 + dims.putIfAbsent("高", p.group(3));
  290 + }
  291 + }
  292 + }
  293 + }
  294 + dims.forEach(known::putIfAbsent);
  295 + }
  296 +
  297 + private static String dimKey(char c) {
  298 + switch (c) {
  299 + case '长': return "长";
  300 + case '宽': return "宽";
  301 + case '高':
  302 + case '厚':
  303 + case '深': return "高";
  304 + default: return null;
  305 + }
  306 + }
  307 +
249 private String err(String msg) { 308 private String err(String msg) {
250 Map<String, Object> m = new LinkedHashMap<>(); 309 Map<String, Object> m = new LinkedHashMap<>();
251 m.put("error", msg); 310 m.put("error", msg);
src/main/java/com/xly/tool/UseSkillTool.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import com.xly.service.LedgerService;
  4 +import com.xly.service.SkillService;
  5 +import dev.langchain4j.agent.tool.P;
  6 +import dev.langchain4j.agent.tool.Tool;
  7 +
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * useSkill 工具 —— 把某项技能(业务流程的权威步骤文本)载入上下文。
  12 + *
  13 + * <p>返回技能全文(本轮立即可用),同时落 {@code skill_active} 事件:投影层把激活技能的全文
  14 + * 钉在「进行中的流程」卡上(见 EventProjectionService),跨轮续办不丢步骤;提议 executed/cancelled
  15 + * 或新技能激活时摘下。
  16 + *
  17 + * <p>由 {@code AgentFactory} 按请求新建:携带会话 id 用于落账。
  18 + */
  19 +public class UseSkillTool {
  20 +
  21 + private final SkillService skills;
  22 + private final LedgerService ledger;
  23 + private final String convId;
  24 +
  25 + public UseSkillTool(SkillService skills, LedgerService ledger, String convId) {
  26 + this.skills = skills;
  27 + this.ledger = ledger;
  28 + this.convId = convId;
  29 + }
  30 +
  31 + @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等"
  32 + + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。")
  33 + public String useSkill(@P("技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据") String skillName) {
  34 + SkillService.Skill s = skills.find(skillName);
  35 + if (s == null) {
  36 + StringBuilder sb = new StringBuilder("没有叫「").append(skillName).append("」的技能。可用技能:\n");
  37 + sb.append(skills.indexLines());
  38 + return sb.toString();
  39 + }
  40 + ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body()));
  41 + return s.body();
  42 + }
  43 +}
src/main/java/com/xly/util/OkHttpUtil.java deleted
1 -package com.xly.util;  
2 -  
3 -import okhttp3.*;  
4 -  
5 -import java.io.IOException;  
6 -import java.util.concurrent.TimeUnit;  
7 -  
8 -/**  
9 - * OkHttp 薄封装:目前仅 {@code LlmJsonClient} 用它同步 POST JSON。  
10 - */  
11 -public class OkHttpUtil {  
12 -  
13 - private final OkHttpClient client;  
14 -  
15 - private OkHttpUtil(long connectTimeout, long readTimeout, long writeTimeout) {  
16 - client = new OkHttpClient.Builder()  
17 - .connectTimeout(connectTimeout, TimeUnit.SECONDS)  
18 - .readTimeout(readTimeout, TimeUnit.SECONDS)  
19 - .writeTimeout(writeTimeout, TimeUnit.SECONDS)  
20 - .build();  
21 - }  
22 -  
23 - public static OkHttpUtil getInstance(long connectTimeout, long readTimeout, long writeTimeout) {  
24 - return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout);  
25 - }  
26 -  
27 - /** POST JSON,带 Bearer 鉴权(OpenAI 兼容端点;Ollama 忽略该头,云端网关需要真实 key)。 */  
28 - public String postJson(String url, String bearerToken, String json) throws IOException {  
29 - RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));  
30 - Request.Builder reqBuilder = new Request.Builder().url(url).post(body);  
31 - if (bearerToken != null && !bearerToken.isBlank()) {  
32 - reqBuilder.header("Authorization", "Bearer " + bearerToken);  
33 - }  
34 - Request request = reqBuilder.build();  
35 - try (Response response = client.newCall(request).execute()) {  
36 - if (!response.isSuccessful()) {  
37 - throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message());  
38 - }  
39 - ResponseBody rb = response.body();  
40 - return rb != null ? rb.string() : "";  
41 - }  
42 - }  
43 -}  
src/main/java/com/xly/web/AgentChatController.java
@@ -3,18 +3,12 @@ package com.xly.web; @@ -3,18 +3,12 @@ package com.xly.web;
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 import com.xly.agent.AgentIdentity; 5 import com.xly.agent.AgentIdentity;
6 -import com.xly.agent.Intent;  
7 import com.xly.agent.ReActAgent; 6 import com.xly.agent.ReActAgent;
8 import com.xly.config.AgentFactory; 7 import com.xly.config.AgentFactory;
9 import com.xly.service.AuthzService; 8 import com.xly.service.AuthzService;
10 import com.xly.service.ConversationService; 9 import com.xly.service.ConversationService;
11 -import com.xly.service.FormResolverService;  
12 -import com.xly.service.IntentService;  
13 import com.xly.service.LedgerService; 10 import com.xly.service.LedgerService;
14 import com.xly.service.OpService; 11 import com.xly.service.OpService;
15 -import com.xly.service.SlotFillService;  
16 -import com.xly.service.StateService;  
17 -import com.xly.tool.FormCollectTool;  
18 import dev.langchain4j.service.TokenStream; 12 import dev.langchain4j.service.TokenStream;
19 import dev.langchain4j.service.tool.ToolExecution; 13 import dev.langchain4j.service.tool.ToolExecution;
20 import org.slf4j.Logger; 14 import org.slf4j.Logger;
@@ -29,7 +23,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -29,7 +23,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
29 23
30 import java.io.IOException; 24 import java.io.IOException;
31 import java.util.LinkedHashMap; 25 import java.util.LinkedHashMap;
32 -import java.util.List;  
33 import java.util.Map; 26 import java.util.Map;
34 import java.util.Set; 27 import java.util.Set;
35 import java.util.concurrent.ExecutorService; 28 import java.util.concurrent.ExecutorService;
@@ -41,18 +34,17 @@ import java.util.regex.Pattern; @@ -41,18 +34,17 @@ import java.util.regex.Pattern;
41 /** 34 /**
42 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 35 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
43 * 36 *
44 - * <p><b>编排(架构 §5 意图门 + 确定性路由)</b>:每轮先用 {@link IntentService} 做**受约束 JSON**  
45 - * 的 4 类意图+实体分类,再确定性路由:  
46 - * <ul>  
47 - * <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单  
48 - * (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);</li>  
49 - * <li><b>操作已有单据</b> → 写槽位抽取 + 确定性 proposeWrite(人在环,具体动作从原话推出);</li>  
50 - * <li><b>查询/其他/分类失败</b> → 统一的 6 工具 ReAct agent(查询附意图 grounding)。</li>  
51 - * </ul>  
52 - * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 37 + * <p><b>编排</b>:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent
  38 + * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。
  39 + * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。
  40 + * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*}
  41 + * 确定性端点、身份内省 fail-closed、工具内鉴权。
53 * 42 *
54 - * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、  
55 - * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 43 + * <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
  44 + * 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。
  45 + *
  46 + * <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清
  47 + * {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。
56 */ 48 */
57 @RestController 49 @RestController
58 @RequestMapping("/api/agent") 50 @RequestMapping("/api/agent")
@@ -68,28 +60,17 @@ public class AgentChatController { @@ -68,28 +60,17 @@ public class AgentChatController {
68 private final ObjectMapper mapper; 60 private final ObjectMapper mapper;
69 private final ConversationService conversations; 61 private final ConversationService conversations;
70 private final OpService ops; 62 private final OpService ops;
71 - private final IntentService intentService;  
72 - private final SlotFillService slotFill;  
73 - private final FormResolverService resolver;  
74 private final LedgerService ledger; 63 private final LedgerService ledger;
75 - private final StateService state;  
76 private final ExecutorService exec = Executors.newCachedThreadPool(); 64 private final ExecutorService exec = Executors.newCachedThreadPool();
77 65
78 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, 66 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
79 - ConversationService conversations, OpService ops,  
80 - IntentService intentService, SlotFillService slotFill,  
81 - FormResolverService resolver, LedgerService ledger,  
82 - StateService state) { 67 + ConversationService conversations, OpService ops, LedgerService ledger) {
83 this.agentFactory = agentFactory; 68 this.agentFactory = agentFactory;
84 this.authz = authz; 69 this.authz = authz;
85 this.mapper = mapper; 70 this.mapper = mapper;
86 this.conversations = conversations; 71 this.conversations = conversations;
87 this.ops = ops; 72 this.ops = ops;
88 - this.intentService = intentService;  
89 - this.slotFill = slotFill;  
90 - this.resolver = resolver;  
91 this.ledger = ledger; 73 this.ledger = ledger;
92 - this.state = state;  
93 } 74 }
94 75
95 public static class ChatReq { 76 public static class ChatReq {
@@ -118,9 +99,9 @@ public class AgentChatController { @@ -118,9 +99,9 @@ public class AgentChatController {
118 99
119 exec.submit(() -> { 100 exec.submit(() -> {
120 try { 101 try {
121 - route(emitter, convId, identity, userInput); 102 + runAgentAttempt(emitter, convId, identity, userInput, true);
122 } catch (Exception e) { 103 } catch (Exception e) {
123 - log.error("agent route failed (conv={})", convId, e); 104 + log.error("agent chat failed (conv={})", convId, e);
124 send(emitter, "error", "服务异常:" + e.getMessage()); 105 send(emitter, "error", "服务异常:" + e.getMessage());
125 emitter.complete(); 106 emitter.complete();
126 } 107 }
@@ -163,8 +144,7 @@ public class AgentChatController { @@ -163,8 +144,7 @@ public class AgentChatController {
163 parts.append(k).append("=").append(v); 144 parts.append(k).append("=").append(v);
164 } 145 }
165 }); 146 });
166 - String userText = "提交「" + entity + "」新增表单:" + parts;  
167 - conversations.touch(identity.userId(), convId, userText); 147 + conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单");
168 ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString())); 148 ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
169 149
170 String fieldsJson; 150 String fieldsJson;
@@ -183,7 +163,6 @@ public class AgentChatController { @@ -183,7 +163,6 @@ public class AgentChatController {
183 ops.attachConversation(opId, convId); 163 ops.attachConversation(opId, convId);
184 String summary = r.path("summary").asText(""); 164 String summary = r.path("summary").asText("");
185 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); 165 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
186 - state.setActiveDoc(convId, entity, "", opId, "proposed");  
187 out.put("opId", opId); 166 out.put("opId", opId);
188 out.put("summary", summary); 167 out.put("summary", summary);
189 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。")); 168 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
@@ -198,108 +177,17 @@ public class AgentChatController { @@ -198,108 +177,17 @@ public class AgentChatController {
198 return out; 177 return out;
199 } 178 }
200 179
201 - /** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */  
202 - private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {  
203 - // 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。  
204 - String digest = state.digest(convId);  
205 - Intent it = intentService.classify(userInput, digest);  
206 - state.recordIntent(convId, it.intent, it.danju);  
207 - state.mergeEntities(convId, it.entities);  
208 - log.info("intent(conv={}): {} / {} / entities={} / missing={}",  
209 - convId, it.intent, it.danju, it.describeEntities(), it.missing);  
210 -  
211 - switch (it.intent) {  
212 - case Intent.CREATE:  
213 - if (handleCreate(emitter, convId, identity, userInput, it)) {  
214 - return;  
215 - }  
216 - // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。  
217 - runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false);  
218 - return;  
219 - case Intent.OPERATE:  
220 - handleWrite(emitter, convId, identity, userInput, it, digest);  
221 - return;  
222 - case Intent.QUERY:  
223 - runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true);  
224 - return;  
225 - default:  
226 - // 其他/分类失败:原文交给 agent,尽量不丢能力。门失败时不能顺带关掉反编造护栏。  
227 - runAgent(emitter, convId, identity, withState(userInput, digest), it.failed);  
228 - }  
229 - }  
230 -  
231 - /** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */  
232 - private static String withState(String text, String digest) {  
233 - if (digest == null || digest.isBlank()) {  
234 - return text;  
235 - }  
236 - return text + "\n\n(会话状态,仅供参考:" + digest + ")";  
237 - }  
238 -  
239 /** 180 /**
240 - * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具,  
241 - * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。  
242 - */  
243 - private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity,  
244 - String userInput, Intent it) {  
245 - String entity = it.danju == null ? "" : it.danju.trim();  
246 - if (entity.isEmpty()) {  
247 - return false;  
248 - }  
249 - Map<String, Object> form = resolver.resolveMasterForm(entity);  
250 - if (form == null) {  
251 - return false;  
252 - }  
253 - String table = String.valueOf(form.get("sDataSource"));  
254 - // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。  
255 - List<Map<String, Object>> fields = resolver.businessFields(table, 40);  
256 - Map<String, String> known = slotFill.buildCreateFields(userInput, entity, fields, it);  
257 -  
258 - FormCollectTool fct = agentFactory.formCollectTool(identity);  
259 - String payload = fct.collectForm(entity, slotFill.toJson(known));  
260 - try {  
261 - JsonNode r = mapper.readTree(payload);  
262 - if ("form_collect".equals(r.path("type").asText(""))) {  
263 - sendEvent(emitter, mapper.convertValue(r, Map.class));  
264 - String hint = "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。";  
265 - send(emitter, "token", hint);  
266 - ledger.append(convId, "form", Map.of("entity", entity, "message", hint));  
267 - state.setActiveDoc(convId, entity, "", "", "collecting");  
268 - send(emitter, "done", "");  
269 - emitter.complete();  
270 - return true;  
271 - }  
272 - // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。  
273 - String err = r.path("error").asText("");  
274 - if (!err.isBlank()) {  
275 - send(emitter, "token", err);  
276 - ledger.append(convId, "assistant", Map.of("text", err));  
277 - send(emitter, "done", "");  
278 - emitter.complete();  
279 - return true;  
280 - }  
281 - } catch (Exception e) {  
282 - log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage());  
283 - }  
284 - return false;  
285 - }  
286 -  
287 - /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */  
288 - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity,  
289 - String text, boolean queryGuard) {  
290 - runAgentAttempt(emitter, convId, identity, text, queryGuard, true);  
291 - }  
292 -  
293 - /**  
294 - * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行):  
295 - * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数,  
296 - * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。 181 + * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
  182 + * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
  183 + * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没
  184 + * proposeWrite → 附纠正提示。
297 */ 185 */
298 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, 186 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
299 - String text, boolean queryGuard, boolean allowRetry) { 187 + String text, boolean allowRetry) {
300 try { 188 try {
301 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示 189 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
302 - ReActAgent agent = agentFactory.build(identity, !allowRetry); 190 + ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
303 AtomicInteger toolCalls = new AtomicInteger(); 191 AtomicInteger toolCalls = new AtomicInteger();
304 AtomicBoolean proposed = new AtomicBoolean(false); 192 AtomicBoolean proposed = new AtomicBoolean(false);
305 TokenStream ts = agent.chat(convId, text); 193 TokenStream ts = agent.chat(convId, text);
@@ -311,13 +199,13 @@ public class AgentChatController { @@ -311,13 +199,13 @@ public class AgentChatController {
311 .onCompleteResponse(resp -> { 199 .onCompleteResponse(resp -> {
312 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null 200 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
313 ? "" : resp.aiMessage().text(); 201 ? "" : resp.aiMessage().text();
314 - if (queryGuard && toolCalls.get() == 0 && hasDigits(answer)) { 202 + if (toolCalls.get() == 0 && hasDigits(answer)) {
315 if (allowRetry) { 203 if (allowRetry) {
316 log.warn("anti-fab retry (conv={}): zero tools + digits", convId); 204 log.warn("anti-fab retry (conv={}): zero tools + digits", convId);
317 send(emitter, "reset", ""); 205 send(emitter, "reset", "");
318 runAgentAttempt(emitter, convId, identity, 206 runAgentAttempt(emitter, convId, identity,
319 "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text, 207 "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text,
320 - true, false); 208 + false);
321 return; 209 return;
322 } 210 }
323 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); 211 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
@@ -354,138 +242,6 @@ public class AgentChatController { @@ -354,138 +242,6 @@ public class AgentChatController {
354 return false; 242 return false;
355 } 243 }
356 244
357 - /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */  
358 - private String ground(String userInput, Intent it) {  
359 - StringBuilder g = new StringBuilder(userInput);  
360 - g.append("\n\n(意图分析,仅供参考:意图=").append(it.intent);  
361 - if (it.danju != null && !it.danju.isBlank()) {  
362 - g.append(",单据/实体类型=").append(it.danju);  
363 - }  
364 - // 用 角色=值 的形式,避免下游把"值(角色)"整体误当作参数值  
365 - StringBuilder pairs = new StringBuilder();  
366 - for (Intent.Entity e : it.entities) {  
367 - if (e == null || e.value == null || e.value.isBlank()) continue;  
368 - if (pairs.length() > 0) pairs.append(",");  
369 - pairs.append(e.role == null ? "其他" : e.role).append("=").append(e.value);  
370 - }  
371 - if (pairs.length() > 0) {  
372 - g.append(",识别到 ").append(pairs);  
373 - }  
374 - g.append("。调用工具时请用上面的**值**作为参数(不要把角色名或括号写进参数),实体角色不要弄错。)");  
375 - return g.toString();  
376 - }  
377 -  
378 - /**  
379 - * 「操作已有单据」处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠),  
380 - * 齐了就确定性调 proposeWrite(它会自定位主表/记录),  
381 - * 缺了就**确定性问一次**并停下——两头都不进失控循环。  
382 - */  
383 - private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity,  
384 - String userInput, Intent it, String digest) {  
385 - Intent.WriteSlots w = intentService.extractWrite(userInput, digest);  
386 - log.info("write-slots(conv={}): entity={} record={} field={} newValue={}",  
387 - convId, w.entityType, w.record, w.field, w.newValue);  
388 -  
389 - String action = deriveWriteAction(userInput);  
390 - String ent = pickEntity(w.entityType, it.danju);  
391 -  
392 - // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环)  
393 - if ("update".equals(action)) {  
394 - java.util.List<String> need = new java.util.ArrayList<>();  
395 - if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)");  
396 - if (isBlank(w.newValue)) need.add("改成什么新值");  
397 - if (!need.isEmpty()) {  
398 - clarifyWrite(emitter, convId, userInput, "修改", w.record, need);  
399 - return;  
400 - }  
401 - } else if (isBlank(w.record)) {  
402 - clarifyWrite(emitter, convId, userInput, actionVerb(action), "",  
403 - java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)"));  
404 - return;  
405 - }  
406 -  
407 - // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果  
408 - String result = agentFactory.proposeWriteTool(identity)  
409 - .proposeWrite(action, ent, w.record, w.field, w.newValue, null);  
410 - emitWriteResult(emitter, convId, userInput, ent, w.record, result);  
411 - }  
412 -  
413 - /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */  
414 - private void emitWriteResult(SseEmitter emitter, String convId, String userInput,  
415 - String ent, String record, String result) {  
416 - try {  
417 - JsonNode r = mapper.readTree(result);  
418 - String opId = r.path("opId").asText(null);  
419 - if (opId != null && !opId.isBlank()) {  
420 - ops.attachConversation(opId, convId);  
421 - String summary = r.path("summary").asText("");  
422 - Map<String, Object> card = new LinkedHashMap<>();  
423 - card.put("type", "write_proposal");  
424 - card.put("opId", opId);  
425 - card.put("summary", summary);  
426 - sendEvent(emitter, card);  
427 - send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。"));  
428 - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));  
429 - state.setActiveDoc(convId, ent, record, opId, "proposed");  
430 - } else {  
431 - String err = r.path("error").asText("无法完成该操作。");  
432 - send(emitter, "token", err);  
433 - ledger.append(convId, "assistant", Map.of("text", err));  
434 - }  
435 - } catch (Exception e) {  
436 - send(emitter, "error", "服务异常:" + e.getMessage());  
437 - }  
438 - send(emitter, "done", "");  
439 - emitter.complete();  
440 - }  
441 -  
442 - /** 实体类型:抽取到的太泛(单据/记录/数据/单)时用意图门的 danju 兜底。 */  
443 - private static String pickEntity(String entityType, String danju) {  
444 - boolean generic = entityType == null || entityType.isBlank()  
445 - || entityType.equals("单据") || entityType.equals("记录")  
446 - || entityType.equals("数据") || entityType.equals("单");  
447 - if (!generic) return entityType.trim();  
448 - return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim());  
449 - }  
450 -  
451 - /** 从用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删;「删除」默认=作废,安全可复原)。 */  
452 - private String deriveWriteAction(String text) {  
453 - String t = text == null ? "" : text;  
454 - if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine";  
455 - if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid";  
456 - if (t.contains("作废")) return "invalid";  
457 - if (t.contains("物理删除") || t.contains("彻底删除")) return "delete";  
458 - if (t.contains("审核") || t.contains("审批")) return "examine";  
459 - if (t.contains("删除") || t.contains("删掉") || t.contains("删了")) return "invalid";  
460 - return "update";  
461 - }  
462 -  
463 - private static String actionVerb(String action) {  
464 - switch (action) {  
465 - case "invalid": return "作废";  
466 - case "cancelInvalid": return "复原";  
467 - case "examine": return "审核";  
468 - case "cancelExamine": return "销审";  
469 - case "delete": return "删除";  
470 - default: return "操作";  
471 - }  
472 - }  
473 -  
474 - private void clarifyWrite(SseEmitter emitter, String convId, String userInput,  
475 - String verb, String record, java.util.List<String> need) {  
476 - String who = isBlank(record) ? "" : ("(记录:" + record + ")");  
477 - String text = "要" + verb + who + ",我还需要您补充:" + String.join("、", need)  
478 - + "。请一起告诉我,我再为你生成待确认的操作。";  
479 - send(emitter, "token", text);  
480 - ledger.append(convId, "clarify", Map.of("text", text));  
481 - send(emitter, "done", "");  
482 - emitter.complete();  
483 - }  
484 -  
485 - private static boolean isBlank(String s) {  
486 - return s == null || s.isBlank();  
487 - }  
488 -  
489 private static String firstNonBlank(String a, String b) { 245 private static String firstNonBlank(String a, String b) {
490 if (a != null && !a.isBlank()) return a; 246 if (a != null && !a.isBlank()) return a;
491 return b; 247 return b;
@@ -507,23 +263,18 @@ public class AgentChatController { @@ -507,23 +263,18 @@ public class AgentChatController {
507 String opId = r.path("opId").asText(null); 263 String opId = r.path("opId").asText(null);
508 if (opId != null && !opId.isBlank()) { 264 if (opId != null && !opId.isBlank()) {
509 ops.attachConversation(opId, convId); 265 ops.attachConversation(opId, convId);
510 - String summary = r.path("summary").asText("");  
511 Map<String, Object> card = new LinkedHashMap<>(); 266 Map<String, Object> card = new LinkedHashMap<>();
512 card.put("type", "write_proposal"); 267 card.put("type", "write_proposal");
513 card.put("opId", opId); 268 card.put("opId", opId);
514 - card.put("summary", summary); 269 + card.put("summary", r.path("summary").asText(""));
515 sendEvent(emitter, card); 270 sendEvent(emitter, card);
516 proposed.set(true); 271 proposed.set(true);
517 - state.setActiveDoc(convId, "", summary, opId, "proposed");  
518 } 272 }
519 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { 273 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
520 JsonNode r = mapper.readTree(te.result()); 274 JsonNode r = mapper.readTree(te.result());
521 String type = r.path("type").asText(""); 275 String type = r.path("type").asText("");
522 - if ("question".equals(type)) {  
523 - sendEvent(emitter, mapper.convertValue(r, Map.class));  
524 - } else if ("form_collect".equals(type)) { 276 + if ("question".equals(type) || "form_collect".equals(type)) {
525 sendEvent(emitter, mapper.convertValue(r, Map.class)); 277 sendEvent(emitter, mapper.convertValue(r, Map.class));
526 - state.setActiveDoc(convId, r.path("entity").asText(""), "", "", "collecting");  
527 } 278 }
528 } 279 }
529 } catch (Exception e) { 280 } catch (Exception e) {
src/main/java/com/xly/web/OpController.java
@@ -10,7 +10,6 @@ import com.xly.service.ErpClient; @@ -10,7 +10,6 @@ import com.xly.service.ErpClient;
10 import com.xly.service.FormResolverService; 10 import com.xly.service.FormResolverService;
11 import com.xly.service.LedgerService; 11 import com.xly.service.LedgerService;
12 import com.xly.service.OpService; 12 import com.xly.service.OpService;
13 -import com.xly.service.StateService;  
14 import org.springframework.http.HttpStatus; 13 import org.springframework.http.HttpStatus;
15 import org.springframework.web.server.ResponseStatusException; 14 import org.springframework.web.server.ResponseStatusException;
16 import org.slf4j.Logger; 15 import org.slf4j.Logger;
@@ -51,7 +50,6 @@ public class OpController { @@ -51,7 +50,6 @@ public class OpController {
51 private final AuditService audit; 50 private final AuditService audit;
52 private final ObjectMapper mapper; 51 private final ObjectMapper mapper;
53 private final LedgerService ledger; 52 private final LedgerService ledger;
54 - private final StateService state;  
55 private final AuthzService authz; 53 private final AuthzService authz;
56 private final ConversationService conversations; 54 private final ConversationService conversations;
57 private final FormResolverService resolver; 55 private final FormResolverService resolver;
@@ -61,7 +59,7 @@ public class OpController { @@ -61,7 +59,7 @@ public class OpController {
61 private boolean execStagingEnabled; 59 private boolean execStagingEnabled;
62 60
63 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, 61 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
64 - LedgerService ledger, StateService state, 62 + LedgerService ledger,
65 AuthzService authz, ConversationService conversations, 63 AuthzService authz, ConversationService conversations,
66 FormResolverService resolver) { 64 FormResolverService resolver) {
67 this.ops = ops; 65 this.ops = ops;
@@ -69,7 +67,6 @@ public class OpController { @@ -69,7 +67,6 @@ public class OpController {
69 this.audit = audit; 67 this.audit = audit;
70 this.mapper = mapper; 68 this.mapper = mapper;
71 this.ledger = ledger; 69 this.ledger = ledger;
72 - this.state = state;  
73 this.authz = authz; 70 this.authz = authz;
74 this.conversations = conversations; 71 this.conversations = conversations;
75 this.resolver = resolver; 72 this.resolver = resolver;
@@ -86,7 +83,6 @@ public class OpController { @@ -86,7 +83,6 @@ public class OpController {
86 "status", status, 83 "status", status,
87 "msg", msg == null ? "" : msg, 84 "msg", msg == null ? "" : msg,
88 "description", description == null ? "" : description)); 85 "description", description == null ? "" : description));
89 - state.updateDocStage(conv, opId, status);  
90 } catch (Exception e) { 86 } catch (Exception e) {
91 log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); 87 log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage());
92 } 88 }
src/main/resources/prompts/system.txt 0 → 100644
  1 +【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。
  2 +
  3 +你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。
  4 +
  5 +【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
  6 +{DOMAIN_MAP}
  7 +【技能清单(业务流程的权威步骤,用 useSkill 载入全文)】
  8 +{SKILL_INDEX}凡要 新建/报价/问价/修改/作废/审核 等会改动数据的业务,或不确定流程怎么走:**先 useSkill 载入对应技能**,再严格按技能步骤执行。简单查询可以直接查。
  9 +
  10 +【可用工具(只有这些)】
  11 +- useSkill(skillName):载入上面某项技能的完整步骤文本。
  12 +- findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
  13 +- readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。
  14 +- lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。
  15 +- collectForm(entityKeyword, knownFieldsJson?):新增单据时弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填(值逐字照抄原话)。
  16 +- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。
  17 +- askUser(question, options?):缺关键信息时向用户提**一个**澄清问题(尽量给选项)。
  18 +
  19 +【硬规则】
  20 +1. 绝不编造:答案里的每个数字、单号、表单名都必须来自工具结果;没查到就如实说没查到。
  21 +2. 写操作一律人在环:只生成待确认提议、**绝不声称已完成/已写入**;提议卡出现就停下,请用户点【确认】。
  22 +3. 实体角色不能错:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,出钱的公司名才是**客户**。引用的客户/产品/物料必须是系统里已有的真实记录——找不到就让用户核对或从下拉里选,**绝不代建**。
  23 +4. 信息足够就直接做,不要无谓反问;缺关键参数才 askUser 问**一次**并停下等回答。回答面向业务人员:用记录名称而不是内部 ID。
  24 +
  25 +若下方出现【进行中的流程】,表示前几轮的业务流程还没走完:续办时严格按其中的技能步骤与单据状态继续,不要从头再来。
src/main/resources/skills/修改记录.md 0 → 100644
  1 +改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段
  2 +【技能:修改记录】
  3 +1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。
  4 +2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。
  5 +3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。
  6 +4. 提议卡出现即停,请用户点【确认】;绝不说已完成。
  7 +注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。
src/main/resources/skills/单据状态操作.md 0 → 100644
  1 +作废/删除、复原、审核、反审核/销审某张已有单据
  2 +【技能:单据状态操作】
  3 +动作对照(proposeWrite 的 action):
  4 +- 删除/取消/不要了 → invalid(作废,可复原)
  5 +- 复原/恢复/取消作废 → cancelInvalid
  6 +- 审核/审核通过 → examine
  7 +- 反审核/销审/撤回审核 → cancelExamine
  8 +- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复
  9 +1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。
  10 +2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。
  11 +3. 工具提示多条匹配:念候选让用户选后再调一次。
  12 +4. 提议卡出现即停,请用户点【确认】;绝不说已完成。
  13 +消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。
src/main/resources/skills/新建单据.md 0 → 100644
  1 +新建报价以外的单据或资料(客户/供应商/物料/订单…)
  2 +【技能:新建单据】
  3 +1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。
  4 +2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。
  5 +3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。
  6 +4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。
src/main/resources/skills/新建报价.md 0 → 100644
  1 +用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价
  2 +【技能:新建报价】
  3 +问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。
  4 +1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。
  5 +2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。
  6 +3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。
  7 +4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。
  8 +5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。
src/main/resources/skills/查询数据.md 0 → 100644
  1 +查数量/概况、找记录、看单条详情、翻页时的标准查法
  2 +【技能:查询数据】
  3 +1. 不确定该查哪张表单:先 findForms(业务关键词);同名多张时优先选检索结果靠前的那张。
  4 +2. 问总数/概况:readFormData 且 keyword 留空;找某个名称的记录:readFormData 填 keyword;要某条记录的完整信息或某个字段(电话/销售员…):lookupRecord。
  5 +3. 用户要下一页/更多:readFormData 同参数、page 加 1。
  6 +4. 答案里的每个数字都必须来自本轮工具结果;工具没查到就如实说没查到。
  7 +5. 回答用记录名称,不用内部 id。给出答案即停。
src/test/java/com/xly/service/ConversationScopeTest.java
@@ -22,8 +22,7 @@ class ConversationScopeTest { @@ -22,8 +22,7 @@ class ConversationScopeTest {
22 22
23 private ConversationService service(StringRedisTemplate redis) { 23 private ConversationService service(StringRedisTemplate redis) {
24 return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(), 24 return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(),
25 - mock(LedgerService.class), mock(StateService.class),  
26 - new EventProjectionService(new ObjectMapper())); 25 + mock(LedgerService.class), new EventProjectionService(new ObjectMapper()));
27 } 26 }
28 27
29 private static AgentIdentity user(String id) { 28 private static AgentIdentity user(String id) {
src/test/java/com/xly/service/SkillServiceTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +
  5 +import java.util.List;
  6 +
  7 +import static org.junit.jupiter.api.Assertions.assertEquals;
  8 +import static org.junit.jupiter.api.Assertions.assertNotNull;
  9 +import static org.junit.jupiter.api.Assertions.assertNull;
  10 +import static org.junit.jupiter.api.Assertions.assertTrue;
  11 +
  12 +/** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */
  13 +class SkillServiceTest {
  14 +
  15 + private final SkillService skills = new SkillService();
  16 +
  17 + @Test
  18 + void allFiveSkillsLoadFromClasspath() {
  19 + List<SkillService.Skill> all = skills.all();
  20 + assertEquals(5, all.size());
  21 + for (String name : new String[]{"查询数据", "新建报价", "新建单据", "修改记录", "单据状态操作"}) {
  22 + SkillService.Skill s = skills.find(name);
  23 + assertNotNull(s, name + " 必须存在");
  24 + assertTrue(s.brief().length() > 4, "首行=一句话用途");
  25 + assertTrue(s.body().contains("【技能:"), "正文为技能全文");
  26 + }
  27 + }
  28 +
  29 + @Test
  30 + void fuzzyNameMatches() {
  31 + assertEquals("新建报价", skills.find("新建报价单").name(), "「新建报价单」→ 新建报价");
  32 + assertEquals("修改记录", skills.find("修改").name());
  33 + assertNull(skills.find("不存在的技能"));
  34 + }
  35 +
  36 + @Test
  37 + void indexHasOneLinePerSkill() {
  38 + String idx = skills.indexLines();
  39 + assertEquals(5, idx.strip().split("\n").length);
  40 + assertTrue(idx.contains("- 新建报价:"));
  41 + }
  42 +}
src/test/java/com/xly/tool/FormCollectDimensionTest.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import org.junit.jupiter.api.Test;
  4 +
  5 +import java.util.LinkedHashMap;
  6 +import java.util.Map;
  7 +
  8 +import static org.junit.jupiter.api.Assertions.assertEquals;
  9 +import static org.junit.jupiter.api.Assertions.assertFalse;
  10 +
  11 +/**
  12 + * 尺寸拆分正则(自 SlotFillService 迁入):模型拆不对「长和宽50cm,高5cm」是实测结论,
  13 + * 预填值里的尺寸表达必须由代码确定性拆成 长/宽/高。
  14 + */
  15 +class FormCollectDimensionTest {
  16 +
  17 + private static Map<String, String> known(String... kv) {
  18 + Map<String, String> m = new LinkedHashMap<>();
  19 + for (int i = 0; i < kv.length; i += 2) {
  20 + m.put(kv[i], kv[i + 1]);
  21 + }
  22 + return m;
  23 + }
  24 +
  25 + @Test
  26 + void positionalSpecSplitsIntoLwh() {
  27 + Map<String, String> m = known("规格", "60*40*30");
  28 + FormCollectTool.expandDimensions(m);
  29 + assertEquals("60", m.get("长"));
  30 + assertEquals("40", m.get("宽"));
  31 + assertEquals("30", m.get("高"));
  32 + assertEquals("60*40*30", m.get("规格"), "原值保留");
  33 + }
  34 +
  35 + @Test
  36 + void labeledSpecWithSharedValueAndUnits() {
  37 + Map<String, String> m = known("尺寸", "长和宽50cm,高5cm");
  38 + FormCollectTool.expandDimensions(m);
  39 + assertEquals("50", m.get("长"));
  40 + assertEquals("50", m.get("宽"));
  41 + assertEquals("5", m.get("高"));
  42 + }
  43 +
  44 + @Test
  45 + void multiplicationSignsAndDecimals() {
  46 + Map<String, String> m = known("规格", "50×30x5.5");
  47 + FormCollectTool.expandDimensions(m);
  48 + assertEquals("50", m.get("长"));
  49 + assertEquals("30", m.get("宽"));
  50 + assertEquals("5.5", m.get("高"));
  51 + }
  52 +
  53 + @Test
  54 + void existingExplicitDimsAreNotOverwritten() {
  55 + Map<String, String> m = known("长", "99", "规格", "60*40*30");
  56 + FormCollectTool.expandDimensions(m);
  57 + assertEquals("99", m.get("长"), "模型已明确给的 长 不被覆盖");
  58 + assertEquals("40", m.get("宽"));
  59 + }
  60 +
  61 + @Test
  62 + void plainValuesAreUntouched() {
  63 + Map<String, String> m = known("数量", "5000", "产品名称", "大16开画册", "联系电话", "0571-88881234");
  64 + FormCollectTool.expandDimensions(m);
  65 + assertFalse(m.containsKey("长"), "普通数值/名称/电话不产生尺寸");
  66 + assertEquals("大16开画册", m.get("产品名称"), "值逐字保留");
  67 + }
  68 +}