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