diff --git a/sql/ai_skill.sql b/sql/ai_skill.sql index 3dbfc3a..7c48766 100644 --- a/sql/ai_skill.sql +++ b/sql/ai_skill.sql @@ -1,25 +1,23 @@ --- ai_skill:Skill 注册表(架构 §6)。 --- Skill = 针对重复任务的 playbook(如 新建报价 / 月度对账 / 库存盘点),用工具但不是工具。 --- 渐进披露:name + 何时用(sWhenToUse) 进 system prompt(便宜、常驻);load_skill(name) 工具 --- 按需注入完整指令(sInstructions)。sSuggested = 建议用到的工具/表单(逗号分隔,仅提示)。 -CREATE TABLE IF NOT EXISTS ai_skill ( +-- ai_skill:技能表(rearch3 §7)。技能 = 业务流程的权威步骤文本,模型用 useSkill 载入后照步骤执行。 +-- +-- 数据源切换开关 = **本表是否存在**(非行数): +-- 表存在 → SkillService 一律只读本表(空表 = 用户要的无技能场景;1 行 = 用户自定义),绝不读 war 包。 +-- 表不存在 → 读 war 包内置 skills/*.md。 +-- DB 瞬时故障 ≠ 回落 war 包(按无技能降级 + 告警,防止用户自定义被默默换回出厂版)。 +-- 因此**建表必须与导入默认技能同一批执行**(建表瞬间即切断包读取)——种子由 +-- sql/gen_ai_skill_sql.py 从 src/main/resources/skills/*.md 生成(见 ai_skill_seed.sql)。 +-- +-- bEnabled:true 载入 / false 不载入,用户可随意设置(与技能内容一样完全自助)。 +-- 旧版 ai_skill(sWhenToUse/sInstructions/sSuggested 草稿,引用已删除的工具)已废弃:DROP 重建。 +DROP TABLE IF EXISTS ai_skill; +CREATE TABLE ai_skill ( sId varchar(64) NOT NULL PRIMARY KEY, - sName varchar(64) NOT NULL, -- Skill 名(load_skill 的入参) - sWhenToUse varchar(255) NOT NULL, -- 何时用(一句话,进 system prompt) - sInstructions text NOT NULL, -- 详细 playbook(load_skill 时注入) - sSuggested varchar(255) NULL, -- 建议工具/表单(逗号分隔) + sName varchar(64) NOT NULL, -- 技能名(useSkill 的入参,进 system prompt 索引) + sBrief varchar(255) NOT NULL, -- 一句话用途(进 system prompt 索引) + sBody text NOT NULL, -- 技能全文(useSkill 载入,≤600 token) bEnabled tinyint NOT NULL DEFAULT 1, - iOrder int NOT NULL DEFAULT 100, - tCreateDate datetime NULL + sUpdatePerson varchar(64) NULL, + tCreateDate datetime NULL DEFAULT CURRENT_TIMESTAMP, + tUpdateDate datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_name (sName) ); - -INSERT INTO ai_skill (sId,sName,sWhenToUse,sInstructions,sSuggested,bEnabled,iOrder,tCreateDate) VALUES -('skill-new-quote','新建报价','用户要为某客户新建/录入一张报价单时', - '新建报价的标准流程:\n1) 先用 findForms("报价") 定位报价主表单,拿到 formId/moduleId。\n2) 报价字段较多(客户、币种、含税、明细行等),用 collectForm(该 formId) 弹出表单让用户逐项填写,而不是逐个追问。\n3) 用户填完后,用 proposeCreate 生成待确认的新增提议;务必让用户在对话内点【确认】后才真正落单。\n4) 报价属于自动执行类写入(bAutoExecute),确认后 ERP 会自动生成正式报价单并回链接。', - 'findForms, collectForm, proposeCreate',1,10,NOW()), -('skill-monthly-ar','月度对账/超期应收催收','用户要做应收对账、查超期未收款、催收时', - '应收对账/催收流程:\n1) 用 queryData 或 findForms("应收") 找到应收台账/收款单据。\n2) 按客户汇总未收金额,用 queryData 做“按客户分组 SUM(未收金额) 并按账龄排序”。\n3) 列出超期(如超过约定账期)客户与金额,提示用户重点催收。\n4) 只读分析,不做任何写操作。', - 'queryData, findForms, readFormData',1,20,NOW()), -('skill-inventory-check','库存查询/盘点','用户要查某物料/产品的库存、库存台账、进出明细时', - '库存查询流程:\n1) 物料库存看 elematerialsstock 相关表单,产品库存看 productstock。\n2) 用 lookupRecord(物料/产品, 名称) 查单个;用 readFormData 看列表;用 queryData 做汇总。\n3) 严禁传 bUpdate 之类写参数(只读)。\n4) 需要出入库明细时找 *_inoutdetail 视图。', - 'lookupRecord, readFormData, queryData',1,30,NOW()); diff --git a/sql/ai_skill_seed.sql b/sql/ai_skill_seed.sql new file mode 100644 index 0000000..b816ff1 --- /dev/null +++ b/sql/ai_skill_seed.sql @@ -0,0 +1,6 @@ +-- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。 +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(); +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(); +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(); +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(); +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(); diff --git a/sql/gen_ai_skill_sql.py b/sql/gen_ai_skill_sql.py new file mode 100644 index 0000000..59d35e6 --- /dev/null +++ b/sql/gen_ai_skill_sql.py @@ -0,0 +1,62 @@ +"""把 skills/*.md 转成 ai_skill 注入 SQL(开发工作流:技能只维护 md 文件,DB 种子由本脚本生成)。 + +用法(uv,无需项目环境): + uv run --no-project sql/gen_ai_skill_sql.py > sql/ai_skill_seed.sql + docker exec -i mysql-saas mysql -uroot -plocal xlyweberp_saas < sql/ai_skill_seed.sql + +语义 = 把 DB 里的**同名技能重置为文件版**(INSERT … ON DUPLICATE KEY UPDATE,按 sName 幂等)。 +⚠️ 会覆盖用户在 DB 里对同名技能的自定义(bEnabled 保留不动);文件里没有的 DB 技能不受影响。 + +文件格式与生产 SkillService 同源:首行=技能名,次行=一句话用途,其余=正文。 +超 600 token(中文≈1字/词元的保守口径)的技能拒绝生成(退出码 1)。 +""" + +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SKILLS_DIR = ROOT / "src/main/resources/skills" +TOKEN_LIMIT = 600 + + +def estimate_tokens(s: str) -> int: + cjk = sum(1 for c in s if ord(c) >= 0x2E80) + other = len(s) - cjk + return cjk + (other + 2) // 3 + + +def q(s: str) -> str: + return "'" + s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") + "'" + + +def main() -> int: + files = sorted(SKILLS_DIR.glob("*.md")) + if not files: + print(f"-- 未找到技能文件:{SKILLS_DIR}", file=sys.stderr) + return 1 + lines = [ + "-- 由 sql/gen_ai_skill_sql.py 生成,勿手改。语义 = 把 DB 同名技能重置为文件版(覆盖自定义,bEnabled 保留)。", + ] + for p in files: + parts = p.read_text(encoding="utf-8").split("\n", 2) + if len(parts) < 3 or not parts[0].strip() or not parts[2].strip(): + print(f"-- 跳过(格式不对,需 首行名/次行用途/正文):{p.name}", file=sys.stderr) + continue + name, brief, body = parts[0].strip(), parts[1].strip(), parts[2].strip() + tokens = estimate_tokens(body) + if tokens > TOKEN_LIMIT: + print(f"技能「{name}」约 {tokens}t,超 {TOKEN_LIMIT}t 上限,拒绝生成", file=sys.stderr) + return 1 + sid = "skill-" + p.stem + lines.append( + f"INSERT INTO ai_skill (sId,sName,sBrief,sBody,sUpdatePerson) " + f"VALUES ({q(sid)},{q(name)},{q(brief)},{q(body)},'skills-md') " + f"ON DUPLICATE KEY UPDATE sBrief=VALUES(sBrief), sBody=VALUES(sBody), " + f"sUpdatePerson='skills-md', tUpdateDate=NOW();" + ) + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java index f4452c1..6ca8f9a 100644 --- a/src/main/java/com/xly/service/SkillService.java +++ b/src/main/java/com/xly/service/SkillService.java @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.io.IOException; @@ -13,29 +14,52 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Stream; /** * skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。 * - *

文件格式:{@code skills/*.md}(文件名用 ASCII——部署链路里 unzip war 会把中文文件名搞乱), - * 首行 = 技能名,次行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。 - * 默认从 classpath 读取(随包发布、缓存一次);配置 {@code xly.skills.dir} 指向文件系统目录后 - * 改从该目录**每次现读**——改文件即热更,不用重启。 + *

数据源切换开关 = MySQL 表 {@code ai_skill} 是否存在(每次现读、不缓存): + *

+ * 零技能优雅降级:索引空渲染、useSkill 答无可用技能、裸工具 ReAct 正常聊。 + * 开发工作流以文件为主:改 {@code skills/*.md} → {@code sql/gen_ai_skill_sql.py} 生成注入 SQL。 */ @Service public class SkillService { private static final Logger log = LoggerFactory.getLogger(SkillService.class); + /** 技能全文的告警阈值(token,保守估算口径):超长挤占流程卡预算。 */ + private static final int BODY_TOKEN_WARN = 600; public record Skill(String name, String brief, String body) { } @Value("${xly.skills.dir:}") private String skillsDir; + private final JdbcTemplate jdbc; + private volatile List classpathCache; + public SkillService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + public List all() { + Boolean table = tableExists(); + if (table == null) { + log.warn("ai_skill 表探测失败(DB 不可用)——按无技能降级,不回落 war 包"); + return List.of(); + } + if (table) { + return loadFromDb(); + } if (skillsDir != null && !skillsDir.isBlank() && Files.isDirectory(Path.of(skillsDir))) { return loadFromDir(Path.of(skillsDir)); } @@ -67,7 +91,7 @@ public class SkillService { return null; } - /** system prompt 的技能索引(一行一个:名称:用途)。 */ + /** system prompt 的技能索引(一行一个:名称:用途)。零技能 → 空串(由 SystemPromptService 降级渲染)。 */ public String indexLines() { StringBuilder sb = new StringBuilder(); for (Skill s : all()) { @@ -76,6 +100,49 @@ public class SkillService { return sb.toString(); } + /** null = DB 不可用(降级无技能);true/false = 表在/不在。jdbc 未装配(测试)按表不存在处理。 */ + private Boolean tableExists() { + if (jdbc == null) { + return false; + } + try { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_skill'", + Integer.class); + return n != null && n > 0; + } catch (Exception e) { + return null; + } + } + + /** DB 全权模式:读失败(含旧 schema 残留)按无技能降级 + 告警,绝不回落 war 包。 */ + private List loadFromDb() { + try { + List> rows = jdbc.queryForList( + "SELECT sName, sBrief, sBody FROM ai_skill WHERE bEnabled=1 ORDER BY sName"); + List out = new ArrayList<>(); + for (Map r : rows) { + Skill s = new Skill(str(r.get("sName")), str(r.get("sBrief")), str(r.get("sBody"))); + if (s.name().isBlank() || s.body().isBlank()) { + continue; + } + int tokens = TokenEstimator.estimate(s.body()); + if (tokens > BODY_TOKEN_WARN) { + log.warn("技能「{}」全文约 {}t,超 {}t 建议上限(仍加载,注意流程卡预算)", s.name(), tokens, BODY_TOKEN_WARN); + } + out.add(s); + } + return out; + } catch (Exception e) { + log.warn("ai_skill 读取失败(schema 不符或 DB 故障)——按无技能降级,不回落 war 包:{}", e.getMessage()); + return List.of(); + } + } + + private static String str(Object o) { + return o == null ? "" : o.toString().trim(); + } + private List loadFromDir(Path dir) { List out = new ArrayList<>(); try (Stream files = Files.list(dir)) { diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index b4ca10f..4070509 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -42,7 +42,11 @@ public class SystemPromptService { d = renderDomainMap(); domainMap = d; } - return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", skills.indexLines()); + String index = skills.indexLines(); + if (index.isBlank()) { // 零技能优雅降级:索引空渲染,裸工具 ReAct 正常聊 + index = "(当前未配置任何技能,直接用下方工具完成任务。)\n"; + } + return t.replace("{DOMAIN_MAP}", d).replace("{SKILL_INDEX}", index); } private static String loadTemplate() { diff --git a/src/main/java/com/xly/tool/UseSkillTool.java b/src/main/java/com/xly/tool/UseSkillTool.java index a44e62d..a722a89 100644 --- a/src/main/java/com/xly/tool/UseSkillTool.java +++ b/src/main/java/com/xly/tool/UseSkillTool.java @@ -30,12 +30,14 @@ public class UseSkillTool { @Tool("载入一项技能(业务流程的权威步骤说明),返回完整步骤文本。凡要 新建/报价/问价/修改/作废/审核 等" + "会改动数据的业务,或不确定流程怎么走,先用它载入对应技能再动手。技能名见 system prompt 的技能清单。") - public String useSkill(@P("技能名,如 新建报价 / 修改记录 / 单据状态操作 / 新建单据 / 查询数据") String skillName) { + public String useSkill(@P("技能名(见 system prompt 的技能清单)") String skillName) { SkillService.Skill s = skills.find(skillName); if (s == null) { - StringBuilder sb = new StringBuilder("没有叫「").append(skillName).append("」的技能。可用技能:\n"); - sb.append(skills.indexLines()); - return sb.toString(); + String index = skills.indexLines(); + if (index.isBlank()) { + return "当前系统未配置任何技能,请直接用其他工具完成任务。"; + } + return "没有叫「" + skillName + "」的技能。可用技能:\n" + index; } ledger.append(convId, "skill_active", Map.of("name", s.name(), "text", s.body())); return s.body(); diff --git a/src/test/java/com/xly/service/SkillServiceTest.java b/src/test/java/com/xly/service/SkillServiceTest.java index b54f61a..a1b4e18 100644 --- a/src/test/java/com/xly/service/SkillServiceTest.java +++ b/src/test/java/com/xly/service/SkillServiceTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** skill 文件加载:5 个技能齐全、首行为用途、正文非空、模糊名可命中。 */ class SkillServiceTest { - private final SkillService skills = new SkillService(); + private final SkillService skills = new SkillService(null); // jdbc 未装配 → 按表不存在 → classpath 模式 @Test void allFiveSkillsLoadFromClasspath() { @@ -39,4 +39,55 @@ class SkillServiceTest { assertEquals(5, idx.strip().split("\n").length); assertTrue(idx.contains("- 新建报价:")); } + + // ---------------- DB 全权模式(表存在开关) ---------------- + + private org.springframework.jdbc.core.JdbcTemplate jdbcWithTable(boolean exists) { + org.springframework.jdbc.core.JdbcTemplate jdbc = + org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class); + org.mockito.Mockito.when(jdbc.queryForObject( + org.mockito.ArgumentMatchers.contains("information_schema"), + org.mockito.ArgumentMatchers.eq(Integer.class))) + .thenReturn(exists ? 1 : 0); + return jdbc; + } + + @Test + void tableExistsMeansDbOnlyEvenIfEmpty() { + org.springframework.jdbc.core.JdbcTemplate jdbc = jdbcWithTable(true); + org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT sName"))) + .thenReturn(java.util.List.of()); + SkillService s = new SkillService(jdbc); + assertTrue(s.all().isEmpty(), "空表 = 用户要的无技能场景,绝不回读 war 包"); + assertTrue(s.indexLines().isBlank()); + assertNull(s.find("新建报价")); + } + + @Test + void tableExistsLoadsCustomSkillsFromDb() { + org.springframework.jdbc.core.JdbcTemplate jdbc = jdbcWithTable(true); + org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT sName"))) + .thenReturn(java.util.List.of(java.util.Map.of( + "sName", "自定义流程", "sBrief", "用户自定义", "sBody", "【技能:自定义流程】1. 步骤"))); + SkillService s = new SkillService(jdbc); + assertEquals(1, s.all().size(), "DB 有 1 行 = 只读这 1 行,不合并 war 包"); + assertEquals("自定义流程", s.find("自定义").name()); + } + + @Test + void dbFailureDegradesToNoSkillsNotClasspath() { + org.springframework.jdbc.core.JdbcTemplate jdbc = + org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class); + org.mockito.Mockito.when(jdbc.queryForObject( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(Integer.class))) + .thenThrow(new RuntimeException("db down")); + SkillService s = new SkillService(jdbc); + assertTrue(s.all().isEmpty(), "DB 故障按无技能降级,绝不回落 war 包"); + } + + @Test + void tableAbsentFallsBackToClasspath() { + SkillService s = new SkillService(jdbcWithTable(false)); + assertEquals(5, s.all().size(), "表不存在 → 读 war 包内置技能"); + } }