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 25 MAX_STEPS = 8
26 26  
27 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 52 def build_context(arch):
32   - """返回 (system_prompt, tools)。new 架构落地后从仓库资源文件读取,避免复刻漂移。"""
  53 + """返回 (system_prompt, tools)。new 架构从仓库资源文件读取(与生产同源),避免复刻漂移。"""
33 54 if arch == "old":
34 55 return UNIFIED_PROMPT, ALL6
35 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 62 raise SystemExit(f"未知 arch: {arch}")
42 63  
43 64  
... ... @@ -97,7 +118,12 @@ def fixture_result(name, args):
97 118 if name == "askUser":
98 119 return "[问题已发给用户,等待回答,本轮到此为止。]"
99 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 127 return "OK"
102 128  
103 129  
... ... @@ -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 428 def main():
332 429 arch = "old"
333 430 only = None
... ... @@ -336,6 +433,9 @@ def main():
336 433 arch = argv[argv.index("--arch") + 1]
337 434 if "--only" in argv:
338 435 only = argv[argv.index("--only") + 1]
  436 + if "--fifty" in argv:
  437 + run_fifty(arch)
  438 + return
339 439 system_prompt, tools = build_context(arch)
340 440 cases = [c for c in CASES if only is None or c["id"] == only]
341 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 5 import dev.langchain4j.service.UserMessage;
6 6  
7 7 /**
8   - * 单一 ReAct 智能体
  8 + * 单一 ReAct 智能体 —— 全部自由文本的唯一入口(没有意图门、没有确定性路由)
9 9 *
10 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 17 public interface ReActAgent {
20 18  
... ...
src/main/java/com/xly/config/AgentFactory.java
... ... @@ -9,12 +9,14 @@ import com.xly.service.EventProjectionService;
9 9 import com.xly.service.FormResolverService;
10 10 import com.xly.service.LedgerService;
11 11 import com.xly.service.OpService;
  12 +import com.xly.service.SkillService;
12 13 import com.xly.service.SystemPromptService;
13 14 import com.xly.tool.ErpReadTool;
14 15 import com.xly.tool.FormCollectTool;
15 16 import com.xly.tool.InteractionTool;
16 17 import com.xly.tool.KgQueryTool;
17 18 import com.xly.tool.ProposeWriteTool;
  19 +import com.xly.tool.UseSkillTool;
18 20 import dev.langchain4j.model.chat.StreamingChatModel;
19 21 import dev.langchain4j.service.AiServices;
20 22 import org.springframework.beans.factory.annotation.Qualifier;
... ... @@ -48,12 +50,14 @@ public class AgentFactory {
48 50  
49 51 private final KgQueryTool kgQueryTool;
50 52 private final InteractionTool interactionTool;
  53 + private final SkillService skillService;
51 54  
52 55 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
53 56 LedgerService ledger, EventProjectionService projection,
54 57 SystemPromptService systemPromptService,
55 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 61 this.streamingModel = streamingModel;
58 62 this.ledger = ledger;
59 63 this.projection = projection;
... ... @@ -65,21 +69,20 @@ public class AgentFactory {
65 69 this.mapper = mapper;
66 70 this.kgQueryTool = kgQueryTool;
67 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 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 84 Object[] tools = new Object[]{kgQueryTool, interactionTool,
  85 + new UseSkillTool(skillService, ledger, convId),
83 86 new ErpReadTool(erp, resolver, identity),
84 87 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
85 88 new FormCollectTool(jdbc, resolver, identity, mapper)};
... ... @@ -95,12 +98,7 @@ public class AgentFactory {
95 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 102 public ProposeWriteTool proposeWriteTool(AgentIdentity identity) {
105 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 30 private final RedisChatMemoryStore memoryStore;
31 31 private final ObjectMapper mapper;
32 32 private final LedgerService ledger;
33   - private final StateService state;
34 33 private final EventProjectionService projection;
35 34  
36 35 public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore,
37   - ObjectMapper mapper, LedgerService ledger, StateService state,
  36 + ObjectMapper mapper, LedgerService ledger,
38 37 EventProjectionService projection) {
39 38 this.redis = redis;
40 39 this.memoryStore = memoryStore;
41 40 this.mapper = mapper;
42 41 this.ledger = ledger;
43   - this.state = state;
44 42 this.projection = projection;
45 43 }
46 44  
... ... @@ -137,7 +135,7 @@ public class ConversationService {
137 135 redis.opsForHash().delete(CONVS_KEY + userId, convId);
138 136 memoryStore.deleteMessages(convId);
139 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 189 switch (type) {
190 190 case "skill_active" -> skillText = str(ev.get("text"));
191 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 197 case "tool_result" -> {
194 198 String name = str(ev.get("name"));
195 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 1 package com.xly.service;
2 2  
  3 +import org.springframework.core.io.ClassPathResource;
3 4 import org.springframework.jdbc.core.JdbcTemplate;
4 5 import org.springframework.stereotype.Service;
5 6  
  7 +import java.nio.charset.StandardCharsets;
6 8 import java.util.List;
7 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 20 @Service
18 21 public class SystemPromptService {
19 22  
20 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 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 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 57 private String renderDomainMap() {
... ...
src/main/java/com/xly/tool/FormCollectTool.java
... ... @@ -13,6 +13,8 @@ import java.util.Iterator;
13 13 import java.util.LinkedHashMap;
14 14 import java.util.List;
15 15 import java.util.Map;
  16 +import java.util.regex.Matcher;
  17 +import java.util.regex.Pattern;
16 18  
17 19 /**
18 20 * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
... ... @@ -33,6 +35,14 @@ public class FormCollectTool {
33 35  
34 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 46 private final JdbcTemplate jdbc;
37 47 private final FormResolverService resolver;
38 48 private final AgentIdentity identity;
... ... @@ -58,6 +68,7 @@ public class FormCollectTool {
58 68 return err("缺少实体类型。");
59 69 }
60 70 Map<String, String> known = parseKnown(knownFieldsJson);
  71 + expandDimensions(known);
61 72 Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
62 73 if (form == null) {
63 74 return err("找不到「" + entityKeyword + "」对应的可新建表单。");
... ... @@ -246,6 +257,54 @@ public class FormCollectTool {
246 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 308 private String err(String msg) {
250 309 Map<String, Object> m = new LinkedHashMap<>();
251 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 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 5 import com.xly.agent.AgentIdentity;
6   -import com.xly.agent.Intent;
7 6 import com.xly.agent.ReActAgent;
8 7 import com.xly.config.AgentFactory;
9 8 import com.xly.service.AuthzService;
10 9 import com.xly.service.ConversationService;
11   -import com.xly.service.FormResolverService;
12   -import com.xly.service.IntentService;
13 10 import com.xly.service.LedgerService;
14 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 12 import dev.langchain4j.service.TokenStream;
19 13 import dev.langchain4j.service.tool.ToolExecution;
20 14 import org.slf4j.Logger;
... ... @@ -29,7 +23,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
29 23  
30 24 import java.io.IOException;
31 25 import java.util.LinkedHashMap;
32   -import java.util.List;
33 26 import java.util.Map;
34 27 import java.util.Set;
35 28 import java.util.concurrent.ExecutorService;
... ... @@ -41,18 +34,17 @@ import java.util.regex.Pattern;
41 34 /**
42 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 49 @RestController
58 50 @RequestMapping("/api/agent")
... ... @@ -68,28 +60,17 @@ public class AgentChatController {
68 60 private final ObjectMapper mapper;
69 61 private final ConversationService conversations;
70 62 private final OpService ops;
71   - private final IntentService intentService;
72   - private final SlotFillService slotFill;
73   - private final FormResolverService resolver;
74 63 private final LedgerService ledger;
75   - private final StateService state;
76 64 private final ExecutorService exec = Executors.newCachedThreadPool();
77 65  
78 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 68 this.agentFactory = agentFactory;
84 69 this.authz = authz;
85 70 this.mapper = mapper;
86 71 this.conversations = conversations;
87 72 this.ops = ops;
88   - this.intentService = intentService;
89   - this.slotFill = slotFill;
90   - this.resolver = resolver;
91 73 this.ledger = ledger;
92   - this.state = state;
93 74 }
94 75  
95 76 public static class ChatReq {
... ... @@ -118,9 +99,9 @@ public class AgentChatController {
118 99  
119 100 exec.submit(() -> {
120 101 try {
121   - route(emitter, convId, identity, userInput);
  102 + runAgentAttempt(emitter, convId, identity, userInput, true);
122 103 } catch (Exception e) {
123   - log.error("agent route failed (conv={})", convId, e);
  104 + log.error("agent chat failed (conv={})", convId, e);
124 105 send(emitter, "error", "服务异常:" + e.getMessage());
125 106 emitter.complete();
126 107 }
... ... @@ -163,8 +144,7 @@ public class AgentChatController {
163 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 148 ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
169 149  
170 150 String fieldsJson;
... ... @@ -183,7 +163,6 @@ public class AgentChatController {
183 163 ops.attachConversation(opId, convId);
184 164 String summary = r.path("summary").asText("");
185 165 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
186   - state.setActiveDoc(convId, entity, "", opId, "proposed");
187 166 out.put("opId", opId);
188 167 out.put("summary", summary);
189 168 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
... ... @@ -198,108 +177,17 @@ public class AgentChatController {
198 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 186 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
299   - String text, boolean queryGuard, boolean allowRetry) {
  187 + String text, boolean allowRetry) {
300 188 try {
301 189 // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
302   - ReActAgent agent = agentFactory.build(identity, !allowRetry);
  190 + ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
303 191 AtomicInteger toolCalls = new AtomicInteger();
304 192 AtomicBoolean proposed = new AtomicBoolean(false);
305 193 TokenStream ts = agent.chat(convId, text);
... ... @@ -311,13 +199,13 @@ public class AgentChatController {
311 199 .onCompleteResponse(resp -> {
312 200 String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
313 201 ? "" : resp.aiMessage().text();
314   - if (queryGuard && toolCalls.get() == 0 && hasDigits(answer)) {
  202 + if (toolCalls.get() == 0 && hasDigits(answer)) {
315 203 if (allowRetry) {
316 204 log.warn("anti-fab retry (conv={}): zero tools + digits", convId);
317 205 send(emitter, "reset", "");
318 206 runAgentAttempt(emitter, convId, identity,
319 207 "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text,
320   - true, false);
  208 + false);
321 209 return;
322 210 }
323 211 send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
... ... @@ -354,138 +242,6 @@ public class AgentChatController {
354 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 245 private static String firstNonBlank(String a, String b) {
490 246 if (a != null && !a.isBlank()) return a;
491 247 return b;
... ... @@ -507,23 +263,18 @@ public class AgentChatController {
507 263 String opId = r.path("opId").asText(null);
508 264 if (opId != null && !opId.isBlank()) {
509 265 ops.attachConversation(opId, convId);
510   - String summary = r.path("summary").asText("");
511 266 Map<String, Object> card = new LinkedHashMap<>();
512 267 card.put("type", "write_proposal");
513 268 card.put("opId", opId);
514   - card.put("summary", summary);
  269 + card.put("summary", r.path("summary").asText(""));
515 270 sendEvent(emitter, card);
516 271 proposed.set(true);
517   - state.setActiveDoc(convId, "", summary, opId, "proposed");
518 272 }
519 273 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
520 274 JsonNode r = mapper.readTree(te.result());
521 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 277 sendEvent(emitter, mapper.convertValue(r, Map.class));
526   - state.setActiveDoc(convId, r.path("entity").asText(""), "", "", "collecting");
527 278 }
528 279 }
529 280 } catch (Exception e) {
... ...
src/main/java/com/xly/web/OpController.java
... ... @@ -10,7 +10,6 @@ import com.xly.service.ErpClient;
10 10 import com.xly.service.FormResolverService;
11 11 import com.xly.service.LedgerService;
12 12 import com.xly.service.OpService;
13   -import com.xly.service.StateService;
14 13 import org.springframework.http.HttpStatus;
15 14 import org.springframework.web.server.ResponseStatusException;
16 15 import org.slf4j.Logger;
... ... @@ -51,7 +50,6 @@ public class OpController {
51 50 private final AuditService audit;
52 51 private final ObjectMapper mapper;
53 52 private final LedgerService ledger;
54   - private final StateService state;
55 53 private final AuthzService authz;
56 54 private final ConversationService conversations;
57 55 private final FormResolverService resolver;
... ... @@ -61,7 +59,7 @@ public class OpController {
61 59 private boolean execStagingEnabled;
62 60  
63 61 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
64   - LedgerService ledger, StateService state,
  62 + LedgerService ledger,
65 63 AuthzService authz, ConversationService conversations,
66 64 FormResolverService resolver) {
67 65 this.ops = ops;
... ... @@ -69,7 +67,6 @@ public class OpController {
69 67 this.audit = audit;
70 68 this.mapper = mapper;
71 69 this.ledger = ledger;
72   - this.state = state;
73 70 this.authz = authz;
74 71 this.conversations = conversations;
75 72 this.resolver = resolver;
... ... @@ -86,7 +83,6 @@ public class OpController {
86 83 "status", status,
87 84 "msg", msg == null ? "" : msg,
88 85 "description", description == null ? "" : description));
89   - state.updateDocStage(conv, opId, status);
90 86 } catch (Exception e) {
91 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 22  
23 23 private ConversationService service(StringRedisTemplate redis) {
24 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 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 +}
... ...