Commit b7543403eef700f0caf27332c1b95533d3efec1f
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)
Showing
26 changed files
with
596 additions
and
1118 deletions
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 | +} |