package vip.mate.skill.synthesis;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import vip.mate.workspace.conversation.model.MessageEntity;
import vip.mate.workspace.conversation.repository.MessageMapper;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* RFC-023: 从对话历史蒸馏 SKILL.md 的服务
*
* 供 {@code POST /api/v1/skills/synthesize-from-conversation} 和前端"建议保存 Skill"流程调用。
* 与 {@code SkillManageTool}(Agent 自治路径)互补——本服务是"用户主动触发"路径。
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillSynthesisService {
private final MessageMapper messageMapper;
private final SkillService skillService;
private final SkillSecurityService securityService;
private final SkillWorkspaceManager workspaceManager;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final SkillSynthesisProperties properties;
private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build();
/**
* 从对话历史合成 Skill
*
* @param conversationId 源对话 ID
* @param agentId Agent ID(用于记录来源)
* @param workspaceId 目标工作区 ID(决定新 Skill 的归属)
* @return 合成结果(包含 skillId、name、status)
*/
public SynthesisResult synthesize(String conversationId, Long agentId, Long workspaceId) {
// 1. 读取对话历史
List messages = messageMapper.selectList(
new LambdaQueryWrapper()
.eq(MessageEntity::getConversationId, conversationId)
.orderByAsc(MessageEntity::getCreateTime));
if (messages.isEmpty()) {
return SynthesisResult.failed("No messages found for conversation " + conversationId);
}
// 2. 压缩对话为 LLM 输入(纯规则,不用 LLM)
String condensed = condenseConversation(messages);
if (condensed.length() < 100) {
return SynthesisResult.failed("Conversation too short to synthesize a meaningful skill");
}
// 3. 调 LLM 生成 SKILL.md
String skillMd;
try {
skillMd = callLlm(condensed);
} catch (Exception e) {
log.error("[SkillSynthesis] LLM call failed for conversation={}: {}", conversationId, e.getMessage(), e);
return SynthesisResult.failed("LLM call failed: " + e.getMessage());
}
if (skillMd == null || skillMd.isBlank()) {
return SynthesisResult.failed("LLM returned empty content");
}
// 4. 提取名称
String name = extractFrontmatterValue(skillMd, "name");
if (name == null || name.isBlank()) {
name = "auto-skill-" + System.currentTimeMillis();
}
name = name.strip().toLowerCase().replaceAll("[^a-z0-9._-]", "-");
// 去重
SkillEntity existing = skillService.findByName(name);
if (existing != null) {
name = name + "-" + (System.currentTimeMillis() % 10000);
}
// 5. 安全扫描
SkillValidationResult scanResult = securityService.scanContent(skillMd, name);
String scanStatus = scanResult.isBlocked() ? "FAILED" : "PASSED";
if (scanResult.isBlocked()) {
log.warn("[SkillSynthesis] Security scan BLOCKED synthesized skill '{}': {}", name, scanResult.getSummary());
return SynthesisResult.blocked(name, scanResult.getSummary());
}
// 6. 保存
try {
SkillEntity skill = new SkillEntity();
skill.setName(name);
skill.setDescription(extractFrontmatterValue(skillMd, "description"));
skill.setSkillType("custom");
skill.setSkillContent(skillMd);
skill.setEnabled(true);
skill.setBuiltin(false);
skill.setVersion(extractFrontmatterValue(skillMd, "version"));
skill.setSourceConversationId(conversationId);
skill.setSecurityScanStatus(scanStatus);
skill.setWorkspaceId(workspaceId);
skillService.createSkill(skill);
try {
workspaceManager.exportToWorkspace(name, skillMd);
} catch (Exception e) {
log.warn("[SkillSynthesis] Workspace export failed for '{}': {}", name, e.getMessage());
}
log.info("[SkillSynthesis] Synthesized skill '{}' from conversation={}, agentId={}", name, conversationId, agentId);
return SynthesisResult.success(skill.getId(), name);
} catch (Exception e) {
log.error("[SkillSynthesis] Failed to save skill '{}': {}", name, e.getMessage(), e);
return SynthesisResult.failed("Save failed: " + e.getMessage());
}
}
/**
* 统计对话中的工具调用数(用于建议器的阈值判断)
*/
public int countToolCalls(String conversationId) {
Long count = messageMapper.selectCount(
new LambdaQueryWrapper()
.eq(MessageEntity::getConversationId, conversationId)
.eq(MessageEntity::getRole, "tool"));
return count != null ? count.intValue() : 0;
}
// ==================== 内部方法 ====================
/**
* 把对话历史压缩为 LLM 可消费的摘要(纯规则,不调 LLM)
*/
private String condenseConversation(List messages) {
StringBuilder sb = new StringBuilder();
int maxLen = 12000; // 控制在 ~3K tokens
for (MessageEntity msg : messages) {
if (sb.length() > maxLen) {
sb.append("\n... (truncated, ").append(messages.size() - messages.indexOf(msg)).append(" messages remaining)");
break;
}
String role = msg.getRole();
String content = msg.getContent();
if (content == null || content.isBlank()) continue;
switch (role) {
case "user" -> {
sb.append("\n### User:\n");
sb.append(truncate(content, 500));
}
case "assistant" -> {
sb.append("\n### Assistant:\n");
sb.append(truncate(content, 800));
}
case "tool" -> {
sb.append("\n### Tool [").append(msg.getToolName() != null ? msg.getToolName() : "unknown").append("]:\n");
// 工具结果只保留前 300 字符(通常很长)
sb.append(truncate(content, 300));
}
// system messages 跳过
}
sb.append("\n");
}
return sb.toString();
}
private String callLlm(String condensed) {
String systemPrompt = PromptLoader.loadPrompt("skill/synthesize-system");
String userTemplate = PromptLoader.loadPrompt("skill/synthesize-user");
String userPrompt = userTemplate.replace("{conversation}", condensed);
ChatModel chatModel = buildChatModel();
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)));
ChatResponse response = chatModel.call(prompt);
if (response == null || response.getResult() == null
|| response.getResult().getOutput() == null) {
return null;
}
String text = response.getResult().getOutput().getText();
// 剥离 markdown 代码块
if (text != null) {
text = text.strip();
if (text.startsWith("```")) {
int firstNewline = text.indexOf('\n');
if (firstNewline > 0) text = text.substring(firstNewline + 1);
}
if (text.endsWith("```")) {
text = text.substring(0, text.length() - 3).strip();
}
}
return text;
}
private ChatModel buildChatModel() {
ModelConfigEntity model = null;
if (properties.getModelId() != null && !properties.getModelId().isBlank()) {
try {
model = modelConfigService.getModel(Long.parseLong(properties.getModelId()));
} catch (Exception e) {
log.warn("[SkillSynthesis] Invalid modelId '{}', falling back to default", properties.getModelId());
}
}
if (model == null) {
model = modelConfigService.getDefaultModel();
}
return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY);
}
private String extractFrontmatterValue(String content, String key) {
if (content == null || !content.startsWith("---")) return null;
int endIdx = content.indexOf("---", 3);
if (endIdx < 0) return null;
String frontmatter = content.substring(3, endIdx);
for (String line : frontmatter.split("\n")) {
String trimmed = line.strip();
if (trimmed.startsWith(key + ":")) {
String value = trimmed.substring(key.length() + 1).strip();
if ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length() - 1);
}
return value;
}
}
return null;
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...";
}
// ==================== 结果 DTO ====================
public record SynthesisResult(
boolean success,
boolean blocked,
Long skillId,
String skillName,
String error,
String scanSummary
) {
public static SynthesisResult success(Long id, String name) {
return new SynthesisResult(true, false, id, name, null, null);
}
public static SynthesisResult failed(String error) {
return new SynthesisResult(false, false, null, null, error, null);
}
public static SynthesisResult blocked(String name, String scanSummary) {
return new SynthesisResult(false, true, null, name, "Security scan blocked", scanSummary);
}
}
}