Commit 82a59f675f63518d78f0977a2694ed50e858ab94
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
Showing
7 changed files
with
223 additions
and
33 deletions
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 | 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 | 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 | 5 | import org.springframework.beans.factory.annotation.Value; |
| 6 | 6 | import org.springframework.core.io.Resource; |
| 7 | 7 | import org.springframework.core.io.support.PathMatchingResourcePatternResolver; |
| 8 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 8 | 9 | import org.springframework.stereotype.Service; |
| 9 | 10 | |
| 10 | 11 | import java.io.IOException; |
| ... | ... | @@ -13,29 +14,52 @@ import java.nio.file.Files; |
| 13 | 14 | import java.nio.file.Path; |
| 14 | 15 | import java.util.ArrayList; |
| 15 | 16 | import java.util.List; |
| 17 | +import java.util.Map; | |
| 16 | 18 | import java.util.stream.Stream; |
| 17 | 19 | |
| 18 | 20 | /** |
| 19 | 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 | 34 | @Service |
| 27 | 35 | public class SkillService { |
| 28 | 36 | |
| 29 | 37 | private static final Logger log = LoggerFactory.getLogger(SkillService.class); |
| 38 | + /** 技能全文的告警阈值(token,保守估算口径):超长挤占流程卡预算。 */ | |
| 39 | + private static final int BODY_TOKEN_WARN = 600; | |
| 30 | 40 | |
| 31 | 41 | public record Skill(String name, String brief, String body) { } |
| 32 | 42 | |
| 33 | 43 | @Value("${xly.skills.dir:}") |
| 34 | 44 | private String skillsDir; |
| 35 | 45 | |
| 46 | + private final JdbcTemplate jdbc; | |
| 47 | + | |
| 36 | 48 | private volatile List<Skill> classpathCache; |
| 37 | 49 | |
| 50 | + public SkillService(JdbcTemplate jdbc) { | |
| 51 | + this.jdbc = jdbc; | |
| 52 | + } | |
| 53 | + | |
| 38 | 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 | 63 | if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) { |
| 40 | 64 | return loadFromDir(Path.of(skillsDir)); |
| 41 | 65 | } |
| ... | ... | @@ -67,7 +91,7 @@ public class SkillService { |
| 67 | 91 | return null; |
| 68 | 92 | } |
| 69 | 93 | |
| 70 | - /** system prompt 的技能索引(一行一个:名称:用途)。 */ | |
| 94 | + /** system prompt 的技能索引(一行一个:名称:用途)。零技能 → 空串(由 SystemPromptService 降级渲染)。 */ | |
| 71 | 95 | public String indexLines() { |
| 72 | 96 | StringBuilder sb = new StringBuilder(); |
| 73 | 97 | for (Skill s : all()) { |
| ... | ... | @@ -76,6 +100,49 @@ public class SkillService { |
| 76 | 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 | 146 | private List<Skill> loadFromDir(Path dir) { |
| 80 | 147 | List<Skill> out = new ArrayList<>(); |
| 81 | 148 | try (Stream<Path> files = Files.list(dir)) { | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -42,7 +42,11 @@ public class SystemPromptService { |
| 42 | 42 | d = renderDomainMap(); |
| 43 | 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 | 52 | private static String loadTemplate() { | ... | ... |
src/main/java/com/xly/tool/UseSkillTool.java
| ... | ... | @@ -30,12 +30,14 @@ public class UseSkillTool { |
| 30 | 30 | |
| 31 | 31 | @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" |
| 32 | 32 | + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。") |
| 33 | - public String useSkill(@P("技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据") String skillName) { | |
| 33 | + public String useSkill(@P("技能名(见 system prompt 的技能清单)") String skillName) { | |
| 34 | 34 | SkillService.Skill s = skills.find(skillName); |
| 35 | 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 | 42 | ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body())); |
| 41 | 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 | 12 | /** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */ |
| 13 | 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 | 17 | @Test |
| 18 | 18 | void allFiveSkillsLoadFromClasspath() { |
| ... | ... | @@ -39,4 +39,55 @@ class SkillServiceTest { |
| 39 | 39 | assertEquals(5, idx.strip().split("\n").length); |
| 40 | 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 | } | ... | ... |