229b09b895
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s
Epic A: ai-assistant 디렉토리/Spring 프록시/프론트엔드 메뉴 완전 제거 Epic B: Flyway 도입 + 13 신규 테이블 마이그레이션 Epic C: 9 서비스 + 7 컨트롤러 + LlmClient 추상화 (Java 21/Spring/MyBatis) Epic D: ApiKey 인증 필터 (sk-pipe-* 키 SHA-256 검증) Epic E: OpenClaw 외부 엔진 docker-compose 통합 Epic F: Next.js 7 페이지 + lib/api/aiAgent.ts 이식 Epic G: 화면 그룹/메뉴 등록 마이그레이션 (V014) Epic H: 통합 빌드 검증 - DB: invyone PostgreSQL에 ai_agents/ai_agent_groups/... 13 테이블 + Quartz - 멀티테넌시: 모든 테이블에 company_code 강제 필터 - LLM: Anthropic/OpenAI/Google/Ollama 직접 클라이언트 (Spring AI 미도입) - 스케줄러: Quartz JDBC JobStore (cron 기반) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
20 KiB
Java
440 lines
20 KiB
Java
package com.erp.ai.service;
|
|
|
|
import com.erp.ai.client.LlmClient;
|
|
import com.erp.ai.client.LlmClientFactory;
|
|
import com.erp.ai.dto.GroupExecutionResult;
|
|
import com.erp.ai.exception.AiAgentException;
|
|
import com.erp.ai.mapper.AiAgentMapper;
|
|
import com.erp.ai.model.AiAgent;
|
|
import com.erp.ai.model.AiAgentConversation;
|
|
import com.erp.ai.model.AiAgentGroup;
|
|
import com.erp.ai.model.AiAgentGroupMember;
|
|
import com.erp.ai.model.AiAgentUsageLog;
|
|
import com.erp.ai.model.AiAnalysisLog;
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Qualifier;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Propagation;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.TreeMap;
|
|
import java.util.concurrent.CompletableFuture;
|
|
import java.util.concurrent.ExecutorService;
|
|
|
|
/**
|
|
* 멀티 에이전트 실행 엔진 (★ 핵심).
|
|
* vexplor multiAgentExecutionEngine.ts 1:1 포팅.
|
|
*
|
|
* - sequential: 1→2→3 순차, 이전 결과를 다음에 전달
|
|
* - parallel: 전체 동시 실행
|
|
* - mixed: execution_order 같으면 병렬, 다르면 순차
|
|
*
|
|
* architecture §7.
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class MultiAgentExecutionEngine {
|
|
|
|
private final AiAgentGroupService groupService;
|
|
private final AiAgentMapper agentMapper;
|
|
private final AiAgentConversationService conversationService;
|
|
private final AiAgentUsageService usageService;
|
|
private final AiAnalysisLogService analysisLogService;
|
|
private final LlmClientFactory llmClientFactory;
|
|
|
|
@Qualifier("aiObjectMapper")
|
|
private final ObjectMapper objectMapper;
|
|
|
|
@Qualifier("aiAgentExecutor")
|
|
private final ExecutorService aiAgentExecutor;
|
|
|
|
/**
|
|
* 그룹 실행 entry point.
|
|
*/
|
|
public GroupExecutionResult execute(long groupId, String userMessage, String userId, Long apiKeyId) {
|
|
AiAgentGroup group = groupService.getEntityById(groupId);
|
|
if (group == null) throw new AiAgentException("멀티 에이전트 그룹을 찾을 수 없습니다.");
|
|
|
|
List<AiAgentGroupMember> members = groupService.listMembers(groupId);
|
|
if (members.isEmpty()) throw new AiAgentException("그룹에 에이전트가 없습니다.");
|
|
|
|
String executionMode = group.getExecution_mode() != null ? group.getExecution_mode() : "mixed";
|
|
long startTime = System.currentTimeMillis();
|
|
|
|
log.info("멀티 에이전트 실행 시작: {} ({}) - \"{}\"", group.getName(), executionMode,
|
|
userMessage.length() > 50 ? userMessage.substring(0, 50) + "..." : userMessage);
|
|
|
|
// 과거 분석 이력 컨텍스트 (try/catch — 이력 없으면 무시)
|
|
String historyContext = "";
|
|
try {
|
|
List<AiAnalysisLog> recentLogs = analysisLogService.getRecentLogs(groupId, 30, 5);
|
|
if (!recentLogs.isEmpty()) {
|
|
StringBuilder sb = new StringBuilder("\n[과거 분석 이력 (최근 5건)]:\n");
|
|
for (AiAnalysisLog log : recentLogs) {
|
|
String date = log.getCreated_at() != null
|
|
? log.getCreated_at().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
|
: "";
|
|
String preview = log.getAnalysis_result() != null && log.getAnalysis_result().length() > 200
|
|
? log.getAnalysis_result().substring(0, 200) + "..."
|
|
: log.getAnalysis_result();
|
|
sb.append("- ").append(date).append(": ").append(preview).append("\n");
|
|
}
|
|
historyContext = sb.toString();
|
|
}
|
|
BigDecimal accuracy = analysisLogService.getAverageAccuracy(groupId);
|
|
if (accuracy != null && accuracy.compareTo(BigDecimal.ZERO) > 0) {
|
|
historyContext += "\n평균 예측 정확도: " + accuracy.setScale(1, java.math.RoundingMode.HALF_UP) + "%";
|
|
}
|
|
} catch (Exception ignored) {
|
|
// 이력 조회 실패는 무시
|
|
}
|
|
|
|
String enrichedMessage = historyContext.isEmpty() ? userMessage : userMessage + "\n\n" + historyContext;
|
|
|
|
List<ExecutionStepResult> stepResults;
|
|
switch (executionMode) {
|
|
case "parallel" -> stepResults = executeParallel(members, enrichedMessage, "");
|
|
case "sequential" -> stepResults = executeSequential(members, enrichedMessage);
|
|
default -> stepResults = executeMixed(members, enrichedMessage);
|
|
}
|
|
|
|
String finalSummary = buildFinalSummary(stepResults, userMessage);
|
|
long totalTokens = stepResults.stream().mapToLong(ExecutionStepResult::tokensUsed).sum();
|
|
long totalDuration = System.currentTimeMillis() - startTime;
|
|
|
|
// 사이드 이펙트 — 별도 트랜잭션 (REQUIRES_NEW)
|
|
try {
|
|
persistResults(group, executionMode, userMessage, stepResults,
|
|
finalSummary, totalTokens, totalDuration, userId, apiKeyId);
|
|
} catch (Exception e) {
|
|
log.warn("멀티 에이전트 결과 적재 실패: {}", e.getMessage());
|
|
}
|
|
|
|
log.info("멀티 에이전트 실행 완료: {} - {} tokens, {}ms", group.getName(), totalTokens, totalDuration);
|
|
|
|
// GroupExecutionResult 매핑
|
|
List<Map<String, Object>> stepsOut = new ArrayList<>();
|
|
for (ExecutionStepResult r : stepResults) {
|
|
Map<String, Object> m = new HashMap<>();
|
|
m.put("order", r.executionOrder());
|
|
m.put("role", r.roleName());
|
|
m.put("agent", r.agentName());
|
|
m.put("model", r.modelName());
|
|
m.put("response", r.response());
|
|
m.put("tokens", r.tokensUsed());
|
|
m.put("duration_ms", r.durationMs());
|
|
m.put("memberId", r.memberId());
|
|
m.put("connectorResults", r.connectorResults());
|
|
stepsOut.add(m);
|
|
}
|
|
return GroupExecutionResult.builder()
|
|
.groupId(group.getId())
|
|
.groupName(group.getName())
|
|
.executionMode(executionMode)
|
|
.steps(stepsOut)
|
|
.finalSummary(finalSummary)
|
|
.totalTokens(totalTokens)
|
|
.totalDurationMs(totalDuration)
|
|
.build();
|
|
}
|
|
|
|
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 30)
|
|
public void persistResults(AiAgentGroup group, String executionMode, String userMessage,
|
|
List<ExecutionStepResult> stepResults, String finalSummary,
|
|
long totalTokens, long totalDuration, String userId, Long apiKeyId) {
|
|
// 대화 저장
|
|
try {
|
|
AiAgentConversation conv = conversationService.createConversation(null, userId, apiKeyId);
|
|
String title = "[" + group.getName() + "] "
|
|
+ (userMessage.length() > 100 ? userMessage.substring(0, 100) : userMessage);
|
|
Map<String, Object> meta = Map.of(
|
|
"group_id", group.getId(),
|
|
"group_name", group.getName(),
|
|
"execution_mode", executionMode);
|
|
conversationService.updateMeta(conv.getId(), title, toJson(meta));
|
|
conversationService.addMessage(conv.getId(), "user", userMessage, 0, null);
|
|
for (ExecutionStepResult step : stepResults) {
|
|
Map<String, Object> stepMeta = Map.of(
|
|
"role_name", step.roleName(),
|
|
"agent_name", step.agentName(),
|
|
"model_name", step.modelName(),
|
|
"execution_order", step.executionOrder(),
|
|
"duration_ms", step.durationMs());
|
|
conversationService.addMessageWithMetadata(conv.getId(), "assistant",
|
|
"[" + step.roleName() + " - " + step.agentName() + "]\n" + step.response(),
|
|
(int) step.tokensUsed(), toJson(stepMeta));
|
|
}
|
|
} catch (Exception e) {
|
|
log.warn("멀티 에이전트 대화 저장 실패: {}", e.getMessage());
|
|
}
|
|
|
|
// 분석 이력 저장
|
|
try {
|
|
AiAnalysisLog analysis = new AiAnalysisLog();
|
|
analysis.setGroup_id(group.getId());
|
|
analysis.setExecution_type(apiKeyId != null ? "api" : "manual");
|
|
analysis.setInput_message(userMessage);
|
|
analysis.setAnalysis_result(finalSummary);
|
|
analysis.setTokens_used((int) totalTokens);
|
|
analysis.setDuration_ms((int) totalDuration);
|
|
analysis.setCompany_code(group.getCompany_code());
|
|
analysisLogService.save(analysis);
|
|
} catch (Exception e) {
|
|
log.warn("분석 이력 저장 실패: {}", e.getMessage());
|
|
}
|
|
|
|
// 사용량 로깅
|
|
try {
|
|
AiAgentUsageLog usage = new AiAgentUsageLog();
|
|
usage.setUser_id(userId);
|
|
usage.setApi_key_id(apiKeyId);
|
|
usage.setTotal_tokens((int) totalTokens);
|
|
usage.setResponse_time_ms((int) totalDuration);
|
|
usage.setSuccess(true);
|
|
usage.setRequest_path("/groups/" + group.getId());
|
|
usage.setCompany_code(group.getCompany_code());
|
|
usageService.log(usage);
|
|
} catch (Exception e) {
|
|
log.warn("사용량 로그 실패: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
private List<ExecutionStepResult> executeSequential(List<AiAgentGroupMember> members, String userMessage) {
|
|
List<AiAgentGroupMember> sorted = new ArrayList<>(members);
|
|
sorted.sort((a, b) -> Integer.compare(a.getExecution_order(), b.getExecution_order()));
|
|
List<ExecutionStepResult> results = new ArrayList<>();
|
|
StringBuilder previousContext = new StringBuilder();
|
|
for (AiAgentGroupMember m : sorted) {
|
|
ExecutionStepResult r = executeSingleAgent(m, userMessage, previousContext.toString());
|
|
results.add(r);
|
|
previousContext.append("\n[").append(m.getRole_name()).append(" 결과]:\n")
|
|
.append(r.response()).append("\n");
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private List<ExecutionStepResult> executeParallel(List<AiAgentGroupMember> members,
|
|
String userMessage, String previousContext) {
|
|
List<CompletableFuture<ExecutionStepResult>> futures = new ArrayList<>();
|
|
for (AiAgentGroupMember m : members) {
|
|
futures.add(CompletableFuture.supplyAsync(
|
|
() -> executeSingleAgent(m, userMessage, previousContext), aiAgentExecutor));
|
|
}
|
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
|
List<ExecutionStepResult> out = new ArrayList<>();
|
|
for (CompletableFuture<ExecutionStepResult> f : futures) {
|
|
out.add(f.join());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private List<ExecutionStepResult> executeMixed(List<AiAgentGroupMember> members, String userMessage) {
|
|
TreeMap<Integer, List<AiAgentGroupMember>> orderGroups = new TreeMap<>();
|
|
for (AiAgentGroupMember m : members) {
|
|
orderGroups.computeIfAbsent(m.getExecution_order(), k -> new ArrayList<>()).add(m);
|
|
}
|
|
List<ExecutionStepResult> all = new ArrayList<>();
|
|
StringBuilder previousContext = new StringBuilder();
|
|
for (Map.Entry<Integer, List<AiAgentGroupMember>> entry : orderGroups.entrySet()) {
|
|
List<AiAgentGroupMember> stage = entry.getValue();
|
|
List<ExecutionStepResult> stageResults;
|
|
if (stage.size() == 1) {
|
|
stageResults = List.of(executeSingleAgent(stage.get(0), userMessage, previousContext.toString()));
|
|
} else {
|
|
stageResults = executeParallel(stage, userMessage, previousContext.toString());
|
|
}
|
|
all.addAll(stageResults);
|
|
for (ExecutionStepResult r : stageResults) {
|
|
previousContext.append("\n[").append(r.roleName()).append(" 결과]:\n")
|
|
.append(r.response()).append("\n");
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private ExecutionStepResult executeSingleAgent(AiAgentGroupMember member,
|
|
String userMessage, String previousContext) {
|
|
long startTime = System.currentTimeMillis();
|
|
AiAgent agent = agentMapper.getById(member.getAgent_id());
|
|
if (agent == null) {
|
|
return new ExecutionStepResult(member.getId(), member.getRole_name(),
|
|
"알 수 없음", "unknown", member.getExecution_order(),
|
|
"에이전트를 찾을 수 없습니다.", 0L, System.currentTimeMillis() - startTime, List.of());
|
|
}
|
|
|
|
// 커넥터 데이터 수집 (invyone에는 외부 커넥터 테이블 부재 — info 만 반환)
|
|
List<Map<String, Object>> connectorResults = new ArrayList<>();
|
|
StringBuilder connectorContext = new StringBuilder();
|
|
List<Map<String, Object>> connectors = parseConnectors(member.getConnectors());
|
|
for (Map<String, Object> c : connectors) {
|
|
Map<String, Object> data = executeConnectorPlaceholder(c);
|
|
Map<String, Object> entry = new HashMap<>();
|
|
entry.put("connector", c.get("name"));
|
|
entry.put("type", c.get("type"));
|
|
entry.put("data", data);
|
|
connectorResults.add(entry);
|
|
connectorContext.append("\n[데이터 소스: ").append(c.get("name"))
|
|
.append(" (").append(c.get("type")).append(")]:\n")
|
|
.append(safeJson(data)).append("\n");
|
|
}
|
|
|
|
// 지식 파일 컨텍스트 (config.knowledge_files)
|
|
String knowledgeContext = buildKnowledgeContext(agent);
|
|
|
|
String systemPrompt = (agent.getSystem_prompt() != null
|
|
? agent.getSystem_prompt() : "당신은 도움이 되는 AI 어시스턴트입니다.")
|
|
+ "\n당신의 역할: " + member.getRole_name()
|
|
+ knowledgeContext
|
|
+ (connectorContext.length() > 0 ? "\n사용 가능한 데이터:\n" + connectorContext : "")
|
|
+ (previousContext != null && !previousContext.isEmpty()
|
|
? "\n이전 에이전트들의 분석 결과:\n" + previousContext : "");
|
|
|
|
// LLM 호출
|
|
try {
|
|
Map<String, Object> agentConfig = parseConfig(agent.getConfig());
|
|
Map<String, Object> llmRequest = new HashMap<>();
|
|
llmRequest.put("model", agent.getModel());
|
|
llmRequest.put("messages", List.of(
|
|
Map.of("role", "system", "content", systemPrompt),
|
|
Map.of("role", "user", "content", userMessage)
|
|
));
|
|
llmRequest.put("max_tokens", agentConfig.getOrDefault("max_tokens", 2000));
|
|
llmRequest.put("temperature", agentConfig.getOrDefault("temperature", 0.7));
|
|
|
|
LlmClient client = llmClientFactory.pick(agent.getModel());
|
|
Map<String, Object> result = client.chat(llmRequest);
|
|
|
|
String text = "응답 없음";
|
|
long tokens = 0L;
|
|
List<Map<String, Object>> choices = (List<Map<String, Object>>) result.getOrDefault("choices", List.of());
|
|
if (!choices.isEmpty()) {
|
|
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
|
|
if (message != null) {
|
|
Object content = message.get("content");
|
|
if (content != null) text = String.valueOf(content);
|
|
}
|
|
}
|
|
Map<String, Object> usage = (Map<String, Object>) result.get("usage");
|
|
if (usage != null && usage.get("total_tokens") instanceof Number n) {
|
|
tokens = n.longValue();
|
|
}
|
|
|
|
return new ExecutionStepResult(member.getId(), member.getRole_name(),
|
|
agent.getName(), agent.getModel(), member.getExecution_order(),
|
|
text, tokens, System.currentTimeMillis() - startTime, connectorResults);
|
|
} catch (Exception e) {
|
|
log.warn("에이전트 실행 실패 ({}): {}", member.getRole_name(), e.getMessage());
|
|
return new ExecutionStepResult(member.getId(), member.getRole_name(),
|
|
agent.getName(), agent.getModel(), member.getExecution_order(),
|
|
"[실행 실패] " + e.getMessage(), 0L,
|
|
System.currentTimeMillis() - startTime, connectorResults);
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private String buildKnowledgeContext(AiAgent agent) {
|
|
try {
|
|
Map<String, Object> config = parseConfig(agent.getConfig());
|
|
Object kfRaw = config.get("knowledge_files");
|
|
if (!(kfRaw instanceof List<?> rawList) || rawList.isEmpty()) return "";
|
|
|
|
StringBuilder out = new StringBuilder("\n[참고 지식 문서]:\n");
|
|
for (Object item : rawList) {
|
|
if (!(item instanceof Map<?, ?> rawMap)) continue;
|
|
Map<String, Object> mapItem = (Map<String, Object>) rawMap;
|
|
Object nameObj = mapItem.get("name");
|
|
String name = nameObj != null ? String.valueOf(nameObj) : "";
|
|
Object content = mapItem.get("content");
|
|
if (content != null) {
|
|
String c = String.valueOf(content);
|
|
out.append("--- ").append(name).append(" ---\n")
|
|
.append(c.length() > 10000 ? c.substring(0, 10000) : c).append("\n\n");
|
|
}
|
|
// library_id resolution은 KnowledgeService 의존성 회피 위해 생략 (향후 통합)
|
|
}
|
|
return out.toString();
|
|
} catch (Exception e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private Map<String, Object> executeConnectorPlaceholder(Map<String, Object> connector) {
|
|
String type = String.valueOf(connector.getOrDefault("type", ""));
|
|
String name = String.valueOf(connector.getOrDefault("name", ""));
|
|
return Map.of("type", type, "name", name, "info", "커넥터 연결 준비됨");
|
|
}
|
|
|
|
private List<Map<String, Object>> parseConnectors(String json) {
|
|
if (json == null || json.isBlank()) return List.of();
|
|
try {
|
|
return objectMapper.readValue(json, new TypeReference<>() {});
|
|
} catch (JsonProcessingException e) {
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
private Map<String, Object> parseConfig(String json) {
|
|
if (json == null || json.isBlank()) return new HashMap<>();
|
|
try {
|
|
return objectMapper.readValue(json, new TypeReference<>() {});
|
|
} catch (JsonProcessingException e) {
|
|
return new HashMap<>();
|
|
}
|
|
}
|
|
|
|
private String safeJson(Object value) {
|
|
try {
|
|
String s = objectMapper.writeValueAsString(value);
|
|
return s.length() > 2000 ? s.substring(0, 2000) : s;
|
|
} catch (JsonProcessingException e) {
|
|
return String.valueOf(value);
|
|
}
|
|
}
|
|
|
|
private String toJson(Object value) {
|
|
try {
|
|
return objectMapper.writeValueAsString(value);
|
|
} catch (JsonProcessingException e) {
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
private String buildFinalSummary(List<ExecutionStepResult> results, String originalQuestion) {
|
|
StringBuilder sb = new StringBuilder("질문: ").append(originalQuestion).append("\n\n");
|
|
for (int i = 0; i < results.size(); i++) {
|
|
ExecutionStepResult r = results.get(i);
|
|
sb.append("[").append(r.roleName()).append(" (").append(r.agentName()).append(")]:\n")
|
|
.append(r.response());
|
|
if (i < results.size() - 1) sb.append("\n\n---\n\n");
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* 단일 에이전트 실행 결과.
|
|
*/
|
|
public record ExecutionStepResult(
|
|
long memberId,
|
|
String roleName,
|
|
String agentName,
|
|
String modelName,
|
|
int executionOrder,
|
|
String response,
|
|
long tokensUsed,
|
|
long durationMs,
|
|
List<Map<String, Object>> connectorResults
|
|
) {}
|
|
}
|