SkillService.java 7.82 KB
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 载入后照步骤执行。
 *
 * <p><b>数据源切换开关 = MySQL 表 {@code ai_skill} 是否存在</b>(每次现读、不缓存):
 * <ul>
 *   <li>表存在 → 一律只读 DB({@code bEnabled=1})。空表 = 用户要的无技能场景、1 行 = 用户自定义,
 *       都**不**回读 war 包,两个数据源永不混用;</li>
 *   <li>表不存在 → 读内置技能:{@code xly.skills.dir} 目录(热更)或 classpath {@code skills/*.md}
 *       (文件名 ASCII——部署链 unzip 会毁中文文件名;首行=技能名,次行=一句话用途,其余=正文);</li>
 *   <li>DB 瞬时故障 ≠ 回落包(按**无技能降级** + 告警,防止用户自定义被默默换回出厂版)。</li>
 * </ul>
 * 零技能优雅降级:索引空渲染、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<Skill> classpathCache;

    public SkillService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public List<Skill> 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<Skill> 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<Skill> 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<Skill> loadFromDb() {
        try {
            List<Map<String, Object>> rows = jdbc.queryForList(
                    "SELECT sName, sBrief, sBody FROM ai_skill WHERE bEnabled=1 ORDER BY sName");
            List<Skill> out = new ArrayList<>();
            for (Map<String, Object> 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<Skill> loadFromDir(Path dir) {
        List<Skill> out = new ArrayList<>();
        try (Stream<Path> 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<Skill> loadFromClasspath() {
        List<Skill> 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);
    }
}