Commit 82a59f675f63518d78f0977a2694ed50e858ab94

Authored by zichun
1 parent ec58ec47

B: skills in MySQL — ai_skill new schema (DROP old draft), table-existence switc…

…h (DB exclusive / classpath fallback / outage degrades to zero skills), bEnabled, md->SQL idempotent seed script, zero-skill graceful prompt
sql/ai_skill.sql
1 --- Skill = 针对重复任务的 playbook(如 新建报价 / 月度对账 / 库存盘点),用工具但不是工具。  
2 --- 渐进披露:name + 何时用(sWhenToUse) 进 system prompt(便宜、常驻);load_skill(name) 工具  
3 --- 按需注入完整指令(sInstructions)。sSuggested = 建议用到的工具/表单(逗号分隔,仅提示)。  
4 -CREATE TABLE IF NOT EXISTS ai_skill ( 1 +-- ai_skill:技能表(rearch3 §7)。技能 = 业务流程的权威步骤文本,模型用 useSkill 载入后照步骤执行。
  2 +--
  3 +-- 数据源切换开关 = **本表是否存在**(非行数):
  4 +-- 表存在 → SkillService 一律只读本表(空表 = 用户要的无技能场景;1 行 = 用户自定义),绝不读 war 包。
  5 +-- 表不存在 → 读 war 包内置 skills/*.md。
  6 +-- DB 瞬时故障 ≠ 回落 war 包(按无技能降级 + 告警,防止用户自定义被默默换回出厂版)。
  7 +-- 因此**建表必须与导入默认技能同一批执行**(建表瞬间即切断包读取)——种子由
  8 +-- sql/gen_ai_skill_sql.py 从 src/main/resources/skills/*.md 生成(见 ai_skill_seed.sql)。
  9 +--
  10 +-- bEnabled:true 载入 / false 不载入,用户可随意设置(与技能内容一样完全自助)。
  11 +-- 旧版 ai_skill(sWhenToUse/sInstructions/sSuggested 草稿,引用已删除的工具)已废弃:DROP 重建。
  12 +DROP TABLE IF EXISTS ai_skill;
  13 +CREATE TABLE ai_skill (
5 sId varchar(64) NOT NULL PRIMARY KEY, 14 sId varchar(64) NOT NULL PRIMARY KEY,
6 - sName varchar(64) NOT NULL, -- Skill 名(load_skill 的入参)  
7 - sWhenToUse varchar(255) NOT NULL, -- 何时用(一句话,进 system prompt)  
8 - sInstructions text NOT NULL, -- 详细 playbook(load_skill 时注入)  
9 - sSuggested varchar(255) NULL, -- 建议工具/表单(逗号分隔) 15 + sName varchar(64) NOT NULL, -- 技能名(useSkill 的入参,进 system prompt 索引)
  16 + sBrief varchar(255) NOT NULL, -- 一句话用途(进 system prompt 索引)
  17 + sBody text NOT NULL, -- 技能全文(useSkill 载入,≤600 token)
10 bEnabled tinyint NOT NULL DEFAULT 1, 18 bEnabled tinyint NOT NULL DEFAULT 1,
11 - iOrder int NOT NULL DEFAULT 100,  
12 - tCreateDate datetime NULL 19 + sUpdatePerson varchar(64) NULL,
  20 + tCreateDate datetime NULL DEFAULT CURRENT_TIMESTAMP,
  21 + tUpdateDate datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  22 + UNIQUE KEY uk_name (sName)
13 ); 23 );
14 -  
15 -INSERT INTO ai_skill (sId,sName,sWhenToUse,sInstructions,sSuggested,bEnabled,iOrder,tCreateDate) VALUES  
16 -('skill-new-quote','新建报价','用户要为某客户新建/录入一张报价单时',  
17 - '新建报价的标准流程:\n1) 先用 findForms("报价") 定位报价主表单,拿到 formId/moduleId。\n2) 报价字段较多(客户、币种、含税、明细行等),用 collectForm(该 formId) 弹出表单让用户逐项填写,而不是逐个追问。\n3) 用户填完后,用 proposeCreate 生成待确认的新增提议;务必让用户在对话内点【确认】后才真正落单。\n4) 报价属于自动执行类写入(bAutoExecute),确认后 ERP 会自动生成正式报价单并回链接。',  
18 - 'findForms, collectForm, proposeCreate',1,10,NOW()),  
19 -('skill-monthly-ar','月度对账/超期应收催收','用户要做应收对账、查超期未收款、催收时',  
20 - '应收对账/催收流程:\n1) 用 queryData 或 findForms("应收") 找到应收台账/收款单据。\n2) 按客户汇总未收金额,用 queryData 做“按客户分组 SUM(未收金额) 并按账龄排序”。\n3) 列出超期(如超过约定账期)客户与金额,提示用户重点催收。\n4) 只读分析,不做任何写操作。',  
21 - 'queryData, findForms, readFormData',1,20,NOW()),  
22 -('skill-inventory-check','库存查询/盘点','用户要查某物料/产品的库存、库存台账、进出明细时',  
23 - '库存查询流程:\n1) 物料库存看 elematerialsstock 相关表单,产品库存看 productstock。\n2) 用 lookupRecord(物料/产品, 名称) 查单个;用 readFormData 看列表;用 queryData 做汇总。\n3) 严禁传 bUpdate 之类写参数(只读)。\n4) 需要出入库明细时找 *_inoutdetail 视图。',  
24 - 'lookupRecord, readFormData, queryData',1,30,NOW());  
sql/ai_skill_seed.sql 0 → 100644
  1 +-- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。
  2 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-query','查询数据','查数量/概况、找记录、看单条详情、翻页时的标准查法','【技能:查询数据】\n1. 不确定该查哪张表单:先 findForms(业务关键词);同名多张时优先选检索结果靠前的那张。\n2. 问总数/概况:readFormData 且 keyword 留空;找某个名称的记录:readFormData 填 keyword;要某条记录的完整信息或某个字段(电话/销售员…):lookupRecord。\n3. 用户要下一页/更多:readFormData 同参数、page 加 1。\n4. 答案里的每个数字都必须来自本轮工具结果;工具没查到就如实说没查到。\n5. 回答用记录名称,不用内部 id。给出答案即停。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  3 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-quote-create','新建报价','用户要报价、或问定制产品价格(多少钱/什么价/报个价)——价格由核价算出,系统里没有现成价','【技能:新建报价】\n问价 = 新建报价单:系统里没有现成价格,价格由 ERP 核价算出。只有给了单号、或明确说查已有报价,才是查询。\n1. collectForm("报价", knownFieldsJson):把用户已说的信息作为 knownFieldsJson 预填;值**逐字照抄原话**(「大16开」就写「大16开」,不得改写)。要报价的物品(纸盒/彩盒/画册…)是**产品**,出钱的公司名才是**客户**。\n2. 表单弹出后本轮结束:提示用户在表单里补齐(客户/产品从下拉里选真实数据)并点【提交】,停下等待。\n3. 用户提交表单后(消息形如「提交「报价」新增表单:…」):proposeWrite(action=create, entityKeyword=报价, fieldsJson=表单字段)。\n4. 提议卡出现即停:提示用户点【确认】。确认前什么都没写入,绝不说「已生成/已完成」。\n5. 用户问价格数字:请他确认后在 ERP 里点核价得到;你自己不报任何价格数字。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  4 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-create','新建单据','新建报价以外的单据或资料(客户/供应商/物料/订单…)','【技能:新建单据】\n1. 单据类型不明确:askUser 问一次要新建哪种单据,停下等回答。\n2. collectForm(单据类型, knownFieldsJson):把用户已说的信息预填,值逐字照抄原话。\n3. 角色对照:出钱的公司=客户;要生产/加工/报价的物品=产品。引用到的客户/产品/物料必须是系统里已存在的真实记录;找不到就请用户核对名称或从下拉里选,绝不代建。\n4. 表单弹出即停,等用户提交;提交后 proposeWrite(action=create);提议卡出现即停,请用户点【确认】,绝不说已完成。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  5 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-status','单据状态操作','作废/删除、复原、审核、反审核/销审某张已有单据','【技能:单据状态操作】\n动作对照(proposeWrite 的 action):\n- 删除/取消/不要了 → invalid(作废,可复原)\n- 复原/恢复/取消作废 → cancelInvalid\n- 审核/审核通过 → examine\n- 反审核/销审/撤回审核 → cancelExamine\n- 用户明说「物理删除/彻底删除」→ delete,并提醒不可恢复\n1. 确认操作哪条记录(名称/单号);上下文里定位不到就 askUser 一次,停下等回答。\n2. proposeWrite(action=对照表动作, entityKeyword=单据类型, recordKeyword=记录名或单号)。\n3. 工具提示多条匹配:念候选让用户选后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n消歧:改「审核人」等名字带“审核”的字段 → 用【修改记录】技能(update),不是本技能。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
  6 +INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) VALUES ('skill-record-update','修改记录','改某条已有记录的某个字段——含改「审核人」这类名字带“审核”的字段','【技能:修改记录】\n1. 需要三样:哪条记录(名称/单号)、改哪个字段(中文名)、新值。缺哪样就 askUser **一次**把缺的问全,停下等回答。\n2. 齐了就 proposeWrite(action=update, entityKeyword=实体类型, recordKeyword=记录名或单号, fieldChinese=字段中文名, newValue=新值);newValue 逐字照抄用户的话。\n3. 工具提示多条匹配:把候选念给用户选,选定后再调一次。\n4. 提议卡出现即停,请用户点【确认】;绝不说已完成。\n注意:改「审核人」「复审人」这类**名字带“审核”的字段**是修改记录(update),不是审核操作。','skills-md') ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), sUpdatePerson='skills-md', tUpdateDate=NOW();
sql/gen_ai_skill_sql.py 0 → 100644
  1 +"""把 skills/*.md 转成 ai_skill 注入 SQL(开发工作流:技能只维护 md 文件,DB 种子由本脚本生成)。
  2 +
  3 +用法(uv,无需项目环境):
  4 + uv run --no-project sql/gen_ai_skill_sql.py > sql/ai_skill_seed.sql
  5 + docker exec -i mysql-saas mysql -uroot -plocal xlyweberp_saas < sql/ai_skill_seed.sql
  6 +
  7 +语义 = 把 DB 里的**同名技能重置为文件版**(INSERT … ON DUPLICATE KEY UPDATE,按 sName 幂等)。
  8 +⚠️ 会覆盖用户在 DB 里对同名技能的自定义(bEnabled 保留不动);文件里没有的 DB 技能不受影响。
  9 +
  10 +文件格式与生产 SkillService 同源:首行=技能名,次行=一句话用途,其余=正文。
  11 +超 600 token(中文≈1字/词元的保守口径)的技能拒绝生成(退出码 1)。
  12 +"""
  13 +
  14 +import pathlib
  15 +import sys
  16 +
  17 +ROOT = pathlib.Path(__file__).resolve().parent.parent
  18 +SKILLS_DIR = ROOT / "src/main/resources/skills"
  19 +TOKEN_LIMIT = 600
  20 +
  21 +
  22 +def estimate_tokens(s: str) -> int:
  23 + cjk = sum(1 for c in s if ord(c) >= 0x2E80)
  24 + other = len(s) - cjk
  25 + return cjk + (other + 2) // 3
  26 +
  27 +
  28 +def q(s: str) -> str:
  29 + return "'" + s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") + "'"
  30 +
  31 +
  32 +def main() -> int:
  33 + files = sorted(SKILLS_DIR.glob("*.md"))
  34 + if not files:
  35 + print(f"-- 未找到技能文件:{SKILLS_DIR}", file=sys.stderr)
  36 + return 1
  37 + lines = [
  38 + "-- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。",
  39 + ]
  40 + for p in files:
  41 + parts = p.read_text(encoding="utf-8").split("\n", 2)
  42 + if len(parts) < 3 or not parts[0].strip() or not parts[2].strip():
  43 + print(f"-- 跳过(格式不对,需 首行名/次行用途/正文):{p.name}", file=sys.stderr)
  44 + continue
  45 + name, brief, body = parts[0].strip(), parts[1].strip(), parts[2].strip()
  46 + tokens = estimate_tokens(body)
  47 + if tokens > TOKEN_LIMIT:
  48 + print(f"技能「{name}」约 {tokens}t,超 {TOKEN_LIMIT}t 上限,拒绝生成", file=sys.stderr)
  49 + return 1
  50 + sid = "skill-" + p.stem
  51 + lines.append(
  52 + f"INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) "
  53 + f"VALUES ({q(sid)},{q(name)},{q(brief)},{q(body)},'skills-md') "
  54 + f"ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), "
  55 + f"sUpdatePerson='skills-md', tUpdateDate=NOW();"
  56 + )
  57 + print("\n".join(lines))
  58 + return 0
  59 +
  60 +
  61 +if __name__ == "__main__":
  62 + sys.exit(main())
src/main/java/com/xly/service/SkillService.java
@@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory;
5 import org.springframework.beans.factory.annotation.Value; 5 import org.springframework.beans.factory.annotation.Value;
6 import org.springframework.core.io.Resource; 6 import org.springframework.core.io.Resource;
7 import org.springframework.core.io.support.PathMatchingResourcePatternResolver; 7 import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
  8 +import org.springframework.jdbc.core.JdbcTemplate;
8 import org.springframework.stereotype.Service; 9 import org.springframework.stereotype.Service;
9 10
10 import java.io.IOException; 11 import java.io.IOException;
@@ -13,29 +14,52 @@ import java.nio.file.Files; @@ -13,29 +14,52 @@ import java.nio.file.Files;
13 import java.nio.file.Path; 14 import java.nio.file.Path;
14 import java.util.ArrayList; 15 import java.util.ArrayList;
15 import java.util.List; 16 import java.util.List;
  17 +import java.util.Map;
16 import java.util.stream.Stream; 18 import java.util.stream.Stream;
17 19
18 /** 20 /**
19 * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。 21 * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。
20 * 22 *
21 - * <p>文件格式:{@code skills/*.md}(文件名用 ASCII——部署链路里 unzip war 会把中文文件名搞乱),  
22 - * 首行 = 技能名,次行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。  
23 - * 默认从 classpath 读取(随包发布、缓存一次);配置 {@code xly.skills.dir} 指向文件系统目录后  
24 - * 改从该目录**每次现读**——改文件即热更,不用重启。 23 + * <p><b>数据源切换开关 = MySQL 表 {@code ai_skill} 是否存在</b>(每次现读、不缓存):
  24 + * <ul>
  25 + * <li>表存在 → 一律只读 DB({@code bEnabled=1})。空表 = 用户要的无技能场景、1 行 = 用户自定义,
  26 + * 都**不**回读 war 包,两个数据源永不混用;</li>
  27 + * <li>表不存在 → 读内置技能:{@code xly.skills.dir} 目录(热更)或 classpath {@code skills/*.md}
  28 + * (文件名 ASCII——部署链 unzip 会毁中文文件名;首行=技能名,次行=一句话用途,其余=正文);</li>
  29 + * <li>DB 瞬时故障 ≠ 回落包(按**无技能降级** + 告警,防止用户自定义被默默换回出厂版)。</li>
  30 + * </ul>
  31 + * 零技能优雅降级:索引空渲染、useSkill 答无可用技能、裸工具 ReAct 正常聊。
  32 + * 开发工作流以文件为主:改 {@code skills/*.md} → {@code sql/gen_ai_skill_sql.py} 生成注入 SQL。
25 */ 33 */
26 @Service 34 @Service
27 public class SkillService { 35 public class SkillService {
28 36
29 private static final Logger log = LoggerFactory.getLogger(SkillService.class); 37 private static final Logger log = LoggerFactory.getLogger(SkillService.class);
  38 + /** 技能全文的告警阈值(token,保守估算口径):超长挤占流程卡预算。 */
  39 + private static final int BODY_TOKEN_WARN = 600;
30 40
31 public record Skill(String name, String brief, String body) { } 41 public record Skill(String name, String brief, String body) { }
32 42
33 @Value("${xly.skills.dir:}") 43 @Value("${xly.skills.dir:}")
34 private String skillsDir; 44 private String skillsDir;
35 45
  46 + private final JdbcTemplate jdbc;
  47 +
36 private volatile List<Skill> classpathCache; 48 private volatile List<Skill> classpathCache;
37 49
  50 + public SkillService(JdbcTemplate jdbc) {
  51 + this.jdbc = jdbc;
  52 + }
  53 +
38 public List<Skill> all() { 54 public List<Skill> all() {
  55 + Boolean table = tableExists();
  56 + if (table == null) {
  57 + log.warn("ai_skill 表探测失败(DB 不可用)——按无技能降级,不回落 war 包");
  58 + return List.of();
  59 + }
  60 + if (table) {
  61 + return loadFromDb();
  62 + }
39 if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) { 63 if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) {
40 return loadFromDir(Path.of(skillsDir)); 64 return loadFromDir(Path.of(skillsDir));
41 } 65 }
@@ -67,7 +91,7 @@ public class SkillService { @@ -67,7 +91,7 @@ public class SkillService {
67 return null; 91 return null;
68 } 92 }
69 93
70 - /** system prompt 的技能索引(一行一个:名称:用途)。 */ 94 + /** system prompt 的技能索引(一行一个:名称:用途)。零技能 → 空串(由 SystemPromptService 降级渲染)。 */
71 public String indexLines() { 95 public String indexLines() {
72 StringBuilder sb = new StringBuilder(); 96 StringBuilder sb = new StringBuilder();
73 for (Skill s : all()) { 97 for (Skill s : all()) {
@@ -76,6 +100,49 @@ public class SkillService { @@ -76,6 +100,49 @@ public class SkillService {
76 return sb.toString(); 100 return sb.toString();
77 } 101 }
78 102
  103 + /** null = DB 不可用(降级无技能);true/false = 表在/不在。jdbc 未装配(测试)按表不存在处理。 */
  104 + private Boolean tableExists() {
  105 + if (jdbc == null) {
  106 + return false;
  107 + }
  108 + try {
  109 + Integer n = jdbc.queryForObject(
  110 + "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_skill'",
  111 + Integer.class);
  112 + return n != null && n > 0;
  113 + } catch (Exception e) {
  114 + return null;
  115 + }
  116 + }
  117 +
  118 + /** DB 全权模式:读失败(含旧 schema 残留)按无技能降级 + 告警,绝不回落 war 包。 */
  119 + private List<Skill> loadFromDb() {
  120 + try {
  121 + List<Map<String, Object>> rows = jdbc.queryForList(
  122 + "SELECT sName, sBrief, sBody FROM ai_skill WHERE bEnabled=1 ORDER BY sName");
  123 + List<Skill> out = new ArrayList<>();
  124 + for (Map<String, Object> r : rows) {
  125 + Skill s = new Skill(str(r.get("sName")), str(r.get("sBrief")), str(r.get("sBody")));
  126 + if (s.name().isBlank() || s.body().isBlank()) {
  127 + continue;
  128 + }
  129 + int tokens = TokenEstimator.estimate(s.body());
  130 + if (tokens > BODY_TOKEN_WARN) {
  131 + log.warn("技能「{}」全文约 {}t,超 {}t 建议上限(仍加载,注意流程卡预算)", s.name(), tokens, BODY_TOKEN_WARN);
  132 + }
  133 + out.add(s);
  134 + }
  135 + return out;
  136 + } catch (Exception e) {
  137 + log.warn("ai_skill 读取失败(schema 不符或 DB 故障)——按无技能降级,不回落 war 包:{}", e.getMessage());
  138 + return List.of();
  139 + }
  140 + }
  141 +
  142 + private static String str(Object o) {
  143 + return o == null ? "" : o.toString().trim();
  144 + }
  145 +
79 private List<Skill> loadFromDir(Path dir) { 146 private List<Skill> loadFromDir(Path dir) {
80 List<Skill> out = new ArrayList<>(); 147 List<Skill> out = new ArrayList<>();
81 try (Stream<Path> files = Files.list(dir)) { 148 try (Stream<Path> files = Files.list(dir)) {
src/main/java/com/xly/service/SystemPromptService.java
@@ -42,7 +42,11 @@ public class SystemPromptService { @@ -42,7 +42,11 @@ public class SystemPromptService {
42 d = renderDomainMap(); 42 d = renderDomainMap();
43 domainMap = d; 43 domainMap = d;
44 } 44 }
45 - return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", skills.indexLines()); 45 + String index = skills.indexLines();
  46 + if (index.isBlank()) { // 零技能优雅降级:索引空渲染,裸工具 ReAct 正常聊
  47 + index = "(当前未配置任何技能,直接用下方工具完成任务。)\n";
  48 + }
  49 + return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", index);
46 } 50 }
47 51
48 private static String loadTemplate() { 52 private static String loadTemplate() {
src/main/java/com/xly/tool/UseSkillTool.java
@@ -30,12 +30,14 @@ public class UseSkillTool { @@ -30,12 +30,14 @@ public class UseSkillTool {
30 30
31 @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" 31 @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等"
32 + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。") 32 + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。")
33 - public String useSkill(@P("技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据") String skillName) { 33 + public String useSkill(@P("技能名(见 system prompt 的技能清单)") String skillName) {
34 SkillService.Skill s = skills.find(skillName); 34 SkillService.Skill s = skills.find(skillName);
35 if (s == null) { 35 if (s == null) {
36 - StringBuilder sb = new StringBuilder("没有叫「").append(skillName).append("」的技能。可用技能:\n");  
37 - sb.append(skills.indexLines());  
38 - return sb.toString(); 36 + String index = skills.indexLines();
  37 + if (index.isBlank()) {
  38 + return "当前系统未配置任何技能,请直接用其他工具完成任务。";
  39 + }
  40 + return "没有叫「" + skillName + "」的技能。可用技能:\n" + index;
39 } 41 }
40 ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body())); 42 ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body()));
41 return s.body(); 43 return s.body();
src/test/java/com/xly/service/SkillServiceTest.java
@@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
12 /** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */ 12 /** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */
13 class SkillServiceTest { 13 class SkillServiceTest {
14 14
15 - private final SkillService skills = new SkillService(); 15 + private final SkillService skills = new SkillService(null); // jdbc 未装配 → 按表不存在 → classpath 模式
16 16
17 @Test 17 @Test
18 void allFiveSkillsLoadFromClasspath() { 18 void allFiveSkillsLoadFromClasspath() {
@@ -39,4 +39,55 @@ class SkillServiceTest { @@ -39,4 +39,55 @@ class SkillServiceTest {
39 assertEquals(5, idx.strip().split("\n").length); 39 assertEquals(5, idx.strip().split("\n").length);
40 assertTrue(idx.contains("- 新建报价:")); 40 assertTrue(idx.contains("- 新建报价:"));
41 } 41 }
  42 +
  43 + // ---------------- DB 全权模式(表存在开关) ----------------
  44 +
  45 + private org.springframework.jdbc.core.JdbcTemplate jdbcWithTable(boolean exists) {
  46 + org.springframework.jdbc.core.JdbcTemplate jdbc =
  47 + org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class);
  48 + org.mockito.Mockito.when(jdbc.queryForObject(
  49 + org.mockito.ArgumentMatchers.contains("information_schema"),
  50 + org.mockito.ArgumentMatchers.eq(Integer.class)))
  51 + .thenReturn(exists ? 1 : 0);
  52 + return jdbc;
  53 + }
  54 +
  55 + @Test
  56 + void tableExistsMeansDbOnlyEvenIfEmpty() {
  57 + org.springframework.jdbc.core.JdbcTemplate jdbc = jdbcWithTable(true);
  58 + org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT sName")))
  59 + .thenReturn(java.util.List.of());
  60 + SkillService s = new SkillService(jdbc);
  61 + assertTrue(s.all().isEmpty(), "空表 = 用户要的无技能场景,绝不回读 war 包");
  62 + assertTrue(s.indexLines().isBlank());
  63 + assertNull(s.find("新建报价"));
  64 + }
  65 +
  66 + @Test
  67 + void tableExistsLoadsCustomSkillsFromDb() {
  68 + org.springframework.jdbc.core.JdbcTemplate jdbc = jdbcWithTable(true);
  69 + org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT sName")))
  70 + .thenReturn(java.util.List.of(java.util.Map.of(
  71 + "sName", "自定义流程", "sBrief", "用户自定义", "sBody", "【技能:自定义流程】1. 步骤")));
  72 + SkillService s = new SkillService(jdbc);
  73 + assertEquals(1, s.all().size(), "DB 有 1 行 = 只读这 1 行,不合并 war 包");
  74 + assertEquals("自定义流程", s.find("自定义").name());
  75 + }
  76 +
  77 + @Test
  78 + void dbFailureDegradesToNoSkillsNotClasspath() {
  79 + org.springframework.jdbc.core.JdbcTemplate jdbc =
  80 + org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class);
  81 + org.mockito.Mockito.when(jdbc.queryForObject(
  82 + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(Integer.class)))
  83 + .thenThrow(new RuntimeException("db down"));
  84 + SkillService s = new SkillService(jdbc);
  85 + assertTrue(s.all().isEmpty(), "DB 故障按无技能降级,绝不回落 war 包");
  86 + }
  87 +
  88 + @Test
  89 + void tableAbsentFallsBackToClasspath() {
  90 + SkillService s = new SkillService(jdbcWithTable(false));
  91 + assertEquals(5, s.all().size(), "表不存在 → 读 war 包内置技能");
  92 + }
42 } 93 }