SkillService.java
7.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
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);
}
}