package com.xly.service; import org.slf4j.Logger; 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; import java.nio.charset.StandardCharsets; 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 载入后照步骤执行。 * *

数据源切换开关 = 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)); } List c = classpathCache; if (c == null) { c = loadFromClasspath(); classpathCache = c; } return c; } /** 按名精确匹配,其次互相包含(「新建报价单」→「新建报价」)。找不到返回 null。 */ public Skill find(String name) { if (name == null || name.isBlank()) { return null; } String n = name.trim(); List skills = all(); for (Skill s : skills) { if (s.name().equals(n)) { return s; } } for (Skill s : skills) { if (n.contains(s.name()) || s.name().contains(n)) { return s; } } return null; } /** system prompt 的技能索引(一行一个:名称:用途)。零技能 → 空串(由 SystemPromptService 降级渲染)。 */ public String indexLines() { StringBuilder sb = new StringBuilder(); for (Skill s : all()) { sb.append("- ").append(s.name()).append(":").append(s.brief).append('\n'); } 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)) { files.filter(p -> p.getFileName().toString().endsWith(".md")).sorted().forEach(p -> { try { Skill s = parse(Files.readString(p, StandardCharsets.UTF_8)); if (s != null) { out.add(s); } } catch (IOException e) { log.warn("skill 读取失败 {}: {}", p, e.getMessage()); } }); } catch (IOException e) { log.warn("skill 目录读取失败 {}: {}", dir, e.getMessage()); } return out; } private List loadFromClasspath() { List out = new ArrayList<>(); try { Resource[] resources = new PathMatchingResourcePatternResolver() .getResources("classpath:skills/*.md"); for (Resource r : resources) { try { Skill s = parse(new String( r.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); if (s != null) { out.add(s); } } catch (IOException e) { log.warn("skill 读取失败 {}: {}", r.getFilename(), e.getMessage()); } } } catch (IOException e) { log.warn("classpath skills 加载失败: {}", e.getMessage()); } out.sort((a, b) -> a.name().compareTo(b.name())); return out; } /** 首行=技能名,次行=一句话用途,其余=正文。 */ private static Skill parse(String content) { if (content == null || content.isBlank()) { return null; } String[] parts = content.split("\n", 3); if (parts.length < 3) { return null; } String name = parts[0].trim(); String brief = parts[1].trim(); String body = parts[2].trim(); if (name.isEmpty() || body.isEmpty()) { return null; } return new Skill(name, brief, body); } }