SkillService.java
4.8 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
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.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.stream.Stream;
/**
* skill 存储 —— 业务流程知识做成文本技能,模型用 useSkill 载入后照步骤执行。
*
* <p>文件格式:{@code skills/*.md}(文件名用 ASCII——部署链路里 unzip war 会把中文文件名搞乱),
* 首行 = 技能名,次行 = 一句话用途(进 system prompt 索引),其余 = 技能正文。
* 默认从 classpath 读取(随包发布、缓存一次);配置 {@code xly.skills.dir} 指向文件系统目录后
* 改从该目录**每次现读**——改文件即热更,不用重启。
*/
@Service
public class SkillService {
private static final Logger log = LoggerFactory.getLogger(SkillService.class);
public record Skill(String name, String brief, String body) { }
@Value("${xly.skills.dir:}")
private String skillsDir;
private volatile List<Skill> classpathCache;
public List<Skill> all() {
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 的技能索引(一行一个:名称:用途)。 */
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();
}
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);
}
}