Files
invyone/backend-spring/src/main/java/com/erp/ai/service/MultiAgentExecutionEngine.java
T
Johngreen 229b09b895
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s
AI관리 시스템 교체: ai-assistant 제거 + 멀티 에이전트 오케스트레이션 이식
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>
2026-04-27 22:49:43 +09:00

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
) {}
}