diff --git a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java index 6b421f5..42d6084 100644 --- a/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java +++ b/src/main/java/com/whylog/server/domain/decision/controller/ApplicationController.java @@ -119,17 +119,4 @@ public ApiResponse disconnectCo return ApiResponse.onSuccess(applicationCommandService.disconnectCommit(applicationId, request)); } - @PostMapping("/{decisionId}/recommendations") - @Operation(summary = "추천 결과 저장 API", description = "적용사항의 추천 결과를 저장하는 API입니다.") - @ApiErrorCodeExamples({ - @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "DECISION_NOT_FOUND"), - @ApiErrorCodeExample(value = GitErrorCode.class, name = "COMMIT_NOT_FOUND") - }) - public ApiResponse saveRecommendation( - @PathVariable Long decisionId, - @Valid @RequestBody DecisionRequest.RecommendationDTO request) { - return ApiResponse.onSuccess(null); - } - } diff --git a/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java b/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java index 0ab3aaf..1c3a635 100644 --- a/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java +++ b/src/main/java/com/whylog/server/domain/decision/controller/DecisionController.java @@ -1,8 +1,11 @@ package com.whylog.server.domain.decision.controller; +import com.fasterxml.jackson.databind.JsonNode; import com.whylog.server.domain.decision.dto.DecisionResponse; import com.whylog.server.domain.decision.exception.DecisionErrorCode; +import com.whylog.server.domain.decision.service.DecisionCommitMatchService; import com.whylog.server.domain.decision.service.DecisionQueryService; +import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; @@ -12,6 +15,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -22,6 +27,7 @@ public class DecisionController { private final DecisionQueryService decisionQueryService; + private final DecisionCommitMatchService decisionCommitMatchService; @GetMapping("/{decisionId}/reliability") @Operation(summary = "신뢰도 조회 API", description = "특정 결정사항의 신뢰도 정보를 조회하는 API입니다.") @@ -33,4 +39,46 @@ public ApiResponse getReliability( @PathVariable Long decisionId) { return ApiResponse.onSuccess(decisionQueryService.getReliability(decisionId)); } + + @PostMapping("/{decisionId}/commit/match") + @Operation( + summary = "결정사항 적용사항-커밋 추천 매칭", + description = """ + 결정사항에 속한 적용사항별 커밋 추천 후보를 FastAPI에 요청하고 결과를 저장합니다. + 회의 분석이 끝난 후 자동으로 1회 실행되며, + 결정사항 페이지 추천 커밋 목록에 있는 새로고침시 해당 API를 호출하면 실행됩니다. + + 결정사항이 포함된 회의의 팀 레포지토리 ID 목록을 생성해 전달합니다. + 추천 개수는 적용사항별 최대 5개로 고정합니다. + 이미 Spring 서버에서 적용사항에 연결된 커밋은 추천 응답과 저장 대상에서 제외합니다. + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "DECISION_NOT_FOUND"), + @ApiErrorCodeExample(value = GitErrorCode.class, name = "REPOSITORY_NOT_FOUND") + }) + public ApiResponse matchApplicationCommits( + @PathVariable Long decisionId) { + return ApiResponse.onSuccess(decisionCommitMatchService.matchApplicationCommits(decisionId)); + } + + @PostMapping("/{decisionId}/commit/match/test") + @Operation( + summary = "결정사항 적용사항-커밋 추천 매칭 저장(테스트용)", + description = """ + FastAPI 호출 없이 전달받은 FastAPI 응답 JSON의 result를 추천 결과로 저장합니다. + + 테스트용 API입니다. `result`를 포함한 전체 응답을 보내도 되고, result 객체만 보내도 됩니다. + 이미 Spring 서버에서 적용사항에 연결된 커밋은 저장 대상에서 제외합니다. + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = DecisionErrorCode.class, name = "DECISION_NOT_FOUND"), + @ApiErrorCodeExample(value = GitErrorCode.class, name = "REPOSITORY_NOT_FOUND") + }) + public ApiResponse saveTestApplicationCommitMatches( + @PathVariable Long decisionId, + @RequestBody JsonNode fastApiResponse) { + return ApiResponse.onSuccess(decisionCommitMatchService.saveTestApplicationCommitMatches(decisionId, fastApiResponse)); + } } diff --git a/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java b/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java index 66a16a0..ec48e53 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java @@ -158,6 +158,9 @@ public static class RecommendedCommitDTO { @Schema(description = "추천 사유", example = "이 커밋은 관련된 이슈를 해결하는 커밋입니다.") private String reason; + + @Schema(description = "추천 신뢰도", example = "94") + private Integer confidence; } @Getter diff --git a/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java b/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java index 3010d3f..aeeef4e 100644 --- a/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java +++ b/src/main/java/com/whylog/server/domain/decision/dto/DecisionRequest.java @@ -32,19 +32,4 @@ public static class CommitDisconnectionDTO { private Long commitId; } - @Getter - @NoArgsConstructor(access = AccessLevel.PROTECTED) - @AllArgsConstructor - @Builder - @Schema(description = "추천 결과 저장 요청") - public static class RecommendationDTO { - - @Schema(description = "추천 커밋 ID", example = "1") - @NotNull - private Long commitId; - - @Schema(description = "추천 이유", example = "이 커밋은 관련된 이슈를 해결하는 커밋입니다.") - private String reason; - } - } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommits.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommits.java index b8c95d4..cee6e78 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommits.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommits.java @@ -24,4 +24,23 @@ public class ApplicationCommits extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "decision_commits_pk", nullable = false) private DecisionCommits decisionCommits; + + @Column(name = "reason", columnDefinition = "TEXT") + private String reason; + + @Column(name = "confidence") + private Integer confidence; + + public static ApplicationCommits create(Application application, + DecisionCommits decisionCommits, + String reason, + Integer confidence) { + ApplicationCommits applicationCommits = new ApplicationCommits(); + applicationCommits.id = new ApplicationCommitsId(application.getId(), decisionCommits.getId()); + applicationCommits.application = application; + applicationCommits.decisionCommits = decisionCommits; + applicationCommits.reason = reason; + applicationCommits.confidence = confidence; + return applicationCommits; + } } diff --git a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommitsId.java b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommitsId.java index 18b74ac..35aec1b 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommitsId.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/ApplicationCommitsId.java @@ -3,6 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.io.Serializable; +import lombok.AllArgsConstructor; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -11,6 +12,7 @@ @Getter @Embeddable @EqualsAndHashCode +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApplicationCommitsId implements Serializable { diff --git a/src/main/java/com/whylog/server/domain/decision/entity/DecisionCommits.java b/src/main/java/com/whylog/server/domain/decision/entity/DecisionCommits.java index 8182026..19f0cc4 100644 --- a/src/main/java/com/whylog/server/domain/decision/entity/DecisionCommits.java +++ b/src/main/java/com/whylog/server/domain/decision/entity/DecisionCommits.java @@ -39,19 +39,10 @@ public class DecisionCommits extends BaseEntity { @Column(name = "commit_id", nullable = false) private Long commitId; // 매핑 X, - @Column(name = "reason", columnDefinition = "TEXT") - private String reason; - - public static DecisionCommits create(Decision decision, Long commitId, String reason) { + public static DecisionCommits create(Decision decision, Long commitId) { DecisionCommits decisionCommits = new DecisionCommits(); decisionCommits.decision = decision; decisionCommits.commitId = commitId; - decisionCommits.reason = reason; return decisionCommits; } - - // 저장시 - public void updateReason(String reason) { - this.reason = reason; - } } diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java index 07f047e..b4000ef 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationCommitsRepository.java @@ -21,6 +21,21 @@ public interface ApplicationCommitsRepository extends JpaRepository findByApplicationId(@Param("applicationId") Long applicationId); + @Query(""" + SELECT AVG(ac.confidence) + FROM ApplicationCommits ac + WHERE ac.application.decision.id = :decisionId + AND ac.confidence IS NOT NULL + """) + Double findAverageConfidenceByDecisionId(@Param("decisionId") Long decisionId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM ApplicationCommits ac + WHERE ac.application.decision.id = :decisionId + """) + void deleteByDecisionId(@Param("decisionId") Long decisionId); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" DELETE FROM ApplicationCommits ac diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java index 5c186b3..d802fa6 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationRepository.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.decision.repository; import com.whylog.server.domain.decision.entity.Application; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,6 +9,8 @@ public interface ApplicationRepository extends JpaRepository { + List findByDecisionId(Long decisionId); + @Modifying @Query("DELETE FROM Application a WHERE a.decision.meeting.team.id = :teamId") void deleteByTeamId(@Param("teamId") Long teamId); diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java index a2dd373..c492e63 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionCommitsRepository.java @@ -13,6 +13,13 @@ public interface DecisionCommitsRepository extends JpaRepository findByDecisionIdAndCommitId(Long decisionId, Long commitId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM DecisionCommits dc + WHERE dc.decision.id = :decisionId + """) + void deleteByDecisionId(@Param("decisionId") Long decisionId); + @Query(""" SELECT dc.id FROM DecisionCommits dc diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java index e943acb..5f6883c 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionRepository.java @@ -11,6 +11,15 @@ public interface DecisionRepository extends JpaRepository { Optional findByMeetingId(Long meetingId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Decision d + SET d.reliabilityScore = :reliabilityScore + WHERE d.id = :decisionId + """) + void updateReliabilityScore(@Param("decisionId") Long decisionId, + @Param("reliabilityScore") Integer reliabilityScore); + @Modifying @Query("DELETE FROM Application a WHERE a.decision.meeting.team.id = :teamId") void deleteApplicationsByTeamId(@Param("teamId") Long teamId); diff --git a/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java b/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java index 413497d..8eecf77 100644 --- a/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java +++ b/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java @@ -159,7 +159,8 @@ private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(Applicat .commitId(String.valueOf(commit.getId())) .commitHash(commit.getHash()) .message(commit.getMessage()) - .reason(applicationCommit.getDecisionCommits().getReason()) + .reason(applicationCommit.getReason()) + .confidence(applicationCommit.getConfidence()) .build(); } diff --git a/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchCandidate.java b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchCandidate.java new file mode 100644 index 0000000..db45666 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchCandidate.java @@ -0,0 +1,88 @@ +package com.whylog.server.domain.decision.service; + +final class DecisionCommitMatchCandidate { + + private final Long applicationId; + private final String applicationTitle; + private final Long commitId; + private final Long repositoryId; + private final String commitHash; + private final String reason; + private final Integer confidence; + private Long resolvedApplicationId; + private Long resolvedCommitId; + + DecisionCommitMatchCandidate(Long applicationId, + String applicationTitle, + Long commitId, + Long repositoryId, + String commitHash, + String reason, + Integer confidence) { + this.applicationId = applicationId; + this.applicationTitle = applicationTitle; + this.commitId = commitId; + this.repositoryId = repositoryId; + this.commitHash = commitHash; + this.reason = reason; + this.confidence = confidence; + } + + Long applicationId() { + return applicationId; + } + + String applicationTitle() { + return applicationTitle; + } + + Long commitId() { + return commitId; + } + + Long repositoryId() { + return repositoryId; + } + + String commitHash() { + return commitHash; + } + + String reason() { + return reason; + } + + Integer confidence() { + return confidence; + } + + void resolveApplicationId(Long resolvedApplicationId) { + this.resolvedApplicationId = resolvedApplicationId; + } + + Long resolvedApplicationId() { + return resolvedApplicationId; + } + + void resolveCommitId(Long resolvedCommitId) { + this.resolvedCommitId = resolvedCommitId; + } + + Long resolvedCommitId() { + return resolvedCommitId; + } + + String commitUniqueKey() { + if (commitId != null) { + return "id:" + commitId; + } + return "repo:" + repositoryId + ":hash:" + commitHash; + } + + String applicationUniqueKey() { + if (applicationId != null) { + return "id:" + applicationId; + } + return "title:" + applicationTitle; + } +} diff --git a/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchKey.java b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchKey.java new file mode 100644 index 0000000..d4bdaf1 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchKey.java @@ -0,0 +1,5 @@ +package com.whylog.server.domain.decision.service; + +//중복제거용 +record DecisionCommitMatchKey(String applicationKey, String commitKey) { +} diff --git a/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchService.java b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchService.java new file mode 100644 index 0000000..bd11f48 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/decision/service/DecisionCommitMatchService.java @@ -0,0 +1,476 @@ +package com.whylog.server.domain.decision.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.whylog.server.domain.decision.entity.Application; +import com.whylog.server.domain.decision.entity.ApplicationCommits; +import com.whylog.server.domain.decision.entity.Decision; +import com.whylog.server.domain.decision.entity.DecisionCommits; +import com.whylog.server.domain.decision.exception.DecisionErrorCode; +import com.whylog.server.domain.decision.exception.DecisionNotFoundException; +import com.whylog.server.domain.decision.repository.ApplicationCommitsRepository; +import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.DecisionCommitsRepository; +import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.repository.CommitConnectionRepository; +import com.whylog.server.domain.git.repository.CommitRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.external.fast.client.FastApiCommitClient; +import com.whylog.server.global.external.fast.client.FastApiSystemClient; +import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.CommitMatchRequest; +import com.whylog.server.global.external.fast.exception.FastApiErrorCode; +import com.whylog.server.global.external.fast.exception.FastApiException; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DecisionCommitMatchService { + + private static final int DEFAULT_TOP_K = 5; + private static final List COMMIT_ID_FIELDS = List.of("commit_id", "commitId"); + private static final List APPLICATION_ID_FIELDS = List.of("application_id", "applicationId"); + private static final List APPLICATION_TITLE_FIELDS = List.of("application_title", "applicationTitle"); + private static final List REPOSITORY_ID_FIELDS = List.of("repository_id", "repositoryId"); + private static final List COMMIT_HASH_FIELDS = List.of("commit_hash", "commitHash"); + private static final List CONFIDENCE_FIELDS = List.of("confidence"); + private static final List REASON_FIELDS = List.of( + "reason", + "recommendation_reason", + "recommendationReason", + "match_reason", + "matchReason", + "explanation" + ); + + private final FastApiCommitClient fastApiCommitClient; + private final FastApiSystemClient fastApiSystemClient; + private final DecisionRepository decisionRepository; + private final ApplicationRepository applicationRepository; + private final DecisionCommitsRepository decisionCommitsRepository; + private final ApplicationCommitsRepository applicationCommitsRepository; + private final RepositoryRepository repositoryRepository; + private final CommitRepository commitRepository; + private final CommitConnectionRepository commitConnectionRepository; + + // 결정사항 기준으로 FastAPI 추천 매칭을 요청하고 추천 결과를 저장 + @Transactional + public JsonNode matchApplicationCommits(Long decisionId) { + Decision decision = decisionRepository.findById(decisionId) + .orElseThrow(DecisionNotFoundException::new); + Long teamId = decision.getMeeting().getTeam().getId(); + List repositoryIds = findTeamRepositoryIds(teamId); + // 외부 연동 장애와 저장 로직 문제를 구분하기 위해 실제 매칭 호출 전에 헬스 체크로 확인 + verifyFastApiAvailable(); + + FastApiResponse response = fastApiCommitClient.matchApplicationCommits( + new CommitMatchRequest(String.valueOf(decision.getMeeting().getId()), repositoryIds, DEFAULT_TOP_K) + ); + + JsonNode result = requireResult(response); + return filterAndSaveRecommendations(decision, repositoryIds, result); + } + + // FastAPI 서버가 매칭 요청을 받을 수 있는 상태인지 확인 + private void verifyFastApiAvailable() { + FastApiResponse> response = fastApiSystemClient.healthCheck(); + if (response == null || Boolean.FALSE.equals(response.isSuccess())) { + throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED); + } + } + + // FastAPI 호출 없이 전달받은 응답 JSON을 저장 로직에 태우는 테스트용 메서드 + @Transactional + public JsonNode saveTestApplicationCommitMatches(Long decisionId, JsonNode fastApiResponse) { + Decision decision = decisionRepository.findById(decisionId) + .orElseThrow(DecisionNotFoundException::new); + Long teamId = decision.getMeeting().getTeam().getId(); + List repositoryIds = findTeamRepositoryIds(teamId); + JsonNode result = extractResult(fastApiResponse); + + return filterAndSaveRecommendations(decision, repositoryIds, result); + } + + // 이미 연결된 커밋을 제외한 뒤 추천 결과 저장 + private JsonNode filterAndSaveRecommendations(Decision decision, List repositoryIds, JsonNode result) { + Set candidateCommitIds = new LinkedHashSet<>(); + collectCommitIds(result, candidateCommitIds); + + if (candidateCommitIds.isEmpty()) { + saveRecommendations(decision, repositoryIds, result); + return result; + } + + Set connectedCommitIds = new HashSet<>( + commitConnectionRepository.findConnectedCommitIds(candidateCommitIds.stream().toList()) + ); + if (connectedCommitIds.isEmpty()) { + saveRecommendations(decision, repositoryIds, result); + return result; + } + + JsonNode filteredResult = result.deepCopy(); + // 이미 사용자가 연결한 커밋은 추천과 저장 대상에서 제외 + removeConnectedCommitRecommendations(filteredResult, connectedCommitIds); + saveRecommendations(decision, repositoryIds, filteredResult); + return filteredResult; + } + + // 테스트 API에서 전체 FastAPI 응답이 들어온 경우 result만 꺼냄 + private JsonNode extractResult(JsonNode fastApiResponse) { + if (fastApiResponse == null || fastApiResponse.isNull()) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + + JsonNode result = fastApiResponse.get("result"); + if (result != null && !result.isNull()) { + return result; + } + return fastApiResponse; + } + + // 결정사항이 속한 팀의 레포지토리 ID 목록을 조회 + private List findTeamRepositoryIds(Long teamId) { + List repositoryIds = repositoryRepository.findByTeamId(teamId).stream() + .map(Repository::getId) + .toList(); + if (repositoryIds.isEmpty()) { + throw new RepositoryNotFoundException(); + } + return repositoryIds; + } + + // FastAPI 응답 전체에서 commit_id 값을 찾음 + private void collectCommitIds(JsonNode node, Set commitIds) { + if (node == null || node.isNull()) { + return; + } + + if (node.isObject()) { + readLong(node, COMMIT_ID_FIELDS).ifPresent(commitIds::add); + node.fields().forEachRemaining(entry -> collectCommitIds(entry.getValue(), commitIds)); + return; + } + + if (node.isArray()) { + node.forEach(child -> collectCommitIds(child, commitIds)); + } + } + + // 이미 적용사항에 연결된 커밋 추천 항목을 응답 JSON에서 제거 + private void removeConnectedCommitRecommendations(JsonNode node, Set connectedCommitIds) { + if (node == null || node.isNull()) { + return; + } + + if (node.isArray()) { + ArrayNode arrayNode = (ArrayNode) node; + Iterator iterator = arrayNode.elements(); + while (iterator.hasNext()) { + JsonNode child = iterator.next(); + Long commitId = readLong(child, COMMIT_ID_FIELDS).orElse(null); + if (commitId != null && connectedCommitIds.contains(commitId)) { + iterator.remove(); + continue; + } + removeConnectedCommitRecommendations(child, connectedCommitIds); + } + return; + } + + if (node.isObject()) { + ObjectNode objectNode = (ObjectNode) node; + objectNode.fields().forEachRemaining(entry -> + removeConnectedCommitRecommendations(entry.getValue(), connectedCommitIds) + ); + } + } + + // 추천 후보를 검증한 뒤 기존 추천 스냅샷을 새 결과로 교체 저장 + private void saveRecommendations(Decision decision, List repositoryIds, JsonNode result) { + List candidates = collectRecommendationCandidates(result); + Map candidatesByKey = candidates.stream() + .collect(Collectors.toMap( + candidate -> new DecisionCommitMatchKey(candidate.applicationUniqueKey(), candidate.commitUniqueKey()), + Function.identity(), + (first, ignored) -> first, + LinkedHashMap::new + )); + + if (candidatesByKey.isEmpty()) { + if (hasRecommendedCommits(result)) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_NOT_FOUND); + } + applicationCommitsRepository.deleteByDecisionId(decision.getId()); + decisionCommitsRepository.deleteByDecisionId(decision.getId()); + decisionRepository.updateReliabilityScore(decision.getId(), null); + return; + } + + Map applicationsById = findAndValidateApplications(decision, candidatesByKey.values()); + Map commitsById = findAndValidateCommits(repositoryIds, candidatesByKey.values()); + + // 새 추천 스냅샷을 저장하기 전에 기존 추천 결과를 통째로 교체 + applicationCommitsRepository.deleteByDecisionId(decision.getId()); + decisionCommitsRepository.deleteByDecisionId(decision.getId()); + + Map decisionCommitsByCommitId = new LinkedHashMap<>(); + for (DecisionCommitMatchCandidate candidate : candidatesByKey.values()) { + Application application = applicationsById.get(candidate.resolvedApplicationId()); + Commit commit = commitsById.get(candidate.resolvedCommitId()); + + DecisionCommits decisionCommits = decisionCommitsByCommitId.computeIfAbsent( + candidate.resolvedCommitId(), + commitId -> decisionCommitsRepository.save(DecisionCommits.create(decision, commitId)) + ); + applicationCommitsRepository.save(ApplicationCommits.create( + application, + decisionCommits, + candidate.reason(), + candidate.confidence() + )); + } + updateReliabilityScore(decision); + } + + // 저장된 추천 매칭 신뢰도 평균을 결정사항 신뢰도 점수로 갱신 + private void updateReliabilityScore(Decision decision) { + Double averageConfidence = applicationCommitsRepository.findAverageConfidenceByDecisionId(decision.getId()); + Integer reliabilityScore = averageConfidence != null ? (int) Math.round(averageConfidence) : null; + decisionRepository.updateReliabilityScore(decision.getId(), reliabilityScore); + } + + // 추천 후보의 적용사항 ID를 검증하고, 없으면 제목으로 보정 + private Map findAndValidateApplications(Decision decision, + java.util.Collection candidates) { + List decisionApplications = applicationRepository.findByDecisionId(decision.getId()); + Map applicationsById = decisionApplications.stream() + .collect(Collectors.toMap(Application::getId, Function.identity())); + Map> applicationsByName = decisionApplications.stream() + .collect(Collectors.groupingBy(Application::getName)); + + for (DecisionCommitMatchCandidate candidate : candidates) { + if (candidate.applicationId() != null) { + if (!applicationsById.containsKey(candidate.applicationId())) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_NOT_FOUND); + } + candidate.resolveApplicationId(candidate.applicationId()); + continue; + } + + // FastAPI가 application_id를 주지 않는 경우 제목으로 현재 decision의 적용사항을 보정 + if (candidate.applicationTitle() == null || candidate.applicationTitle().isBlank()) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_NOT_FOUND); + } + + List matchedApplications = applicationsByName.get(candidate.applicationTitle()); + if (matchedApplications == null || matchedApplications.size() != 1) { + throw new ErrorHandler(DecisionErrorCode.APPLICATION_NOT_FOUND); + } + candidate.resolveApplicationId(matchedApplications.get(0).getId()); + } + + return applicationsById; + } + + // 추천 후보의 커밋 ID를 검증하고, 없으면 repository_id와 hash로 보정 + private Map findAndValidateCommits(List repositoryIds, + java.util.Collection candidates) { + Set repositoryIdSet = new HashSet<>(repositoryIds); + Map commitsById = new LinkedHashMap<>(); + + for (DecisionCommitMatchCandidate candidate : candidates) { + Commit commit = resolveCommit(candidate, repositoryIdSet); + candidate.resolveCommitId(commit.getId()); + commitsById.put(commit.getId(), commit); + } + + return commitsById; + } + + // 추천 후보 하나를 실제 DB 커밋 엔티티로 해석 + private Commit resolveCommit(DecisionCommitMatchCandidate candidate, Set repositoryIdSet) { + if (candidate.commitId() != null) { + Commit commit = commitRepository.findAllWithRepositoryByIdIn(List.of(candidate.commitId())).stream() + .findFirst() + .orElseThrow(() -> new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND)); + if (!repositoryIdSet.contains(commit.getRepository().getId())) { + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_FOUND); + } + return commit; + } + + // FastAPI가 commit_id 대신 hash만 주는 경우 repository_id + hash 조합으로 커밋을 찾음 + if (candidate.repositoryId() == null || candidate.commitHash() == null || candidate.commitHash().isBlank()) { + throw new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND); + } + if (!repositoryIdSet.contains(candidate.repositoryId())) { + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_IN_TEAM); + } + + return commitRepository.findByRepositoryIdAndHash(candidate.repositoryId(), candidate.commitHash()) + .orElseThrow(() -> new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND)); + } + + // FastAPI 응답에서 저장 가능한 추천 후보 목록을 만듦 + private List collectRecommendationCandidates(JsonNode result) { + List candidates = new java.util.ArrayList<>(); + collectRecommendationCandidates(result, null, candidates); + return candidates; + } + + // 응답 JSON을 순회하며 적용사항-커밋 추천 후보를 추 + private void collectRecommendationCandidates(JsonNode node, Long applicationId, List candidates) { + if (node == null || node.isNull()) { + return; + } + + Long currentApplicationId = readLong(node, APPLICATION_ID_FIELDS).orElse(applicationId); + String currentApplicationTitle = readText(node, APPLICATION_TITLE_FIELDS).orElse(null); + Long commitId = readLong(node, COMMIT_ID_FIELDS).orElse(null); + Long repositoryId = readLong(node, REPOSITORY_ID_FIELDS).orElse(null); + String commitHash = readText(node, COMMIT_HASH_FIELDS).orElse(null); + Integer confidence = readLong(node, CONFIDENCE_FIELDS) + .map(Long::intValue) + .orElse(null); + if (currentApplicationId != null && commitId != null) { + candidates.add(new DecisionCommitMatchCandidate( + currentApplicationId, + currentApplicationTitle, + commitId, + repositoryId, + commitHash, + readText(node, REASON_FIELDS).orElse(null), + confidence + )); + } else if (currentApplicationId != null && commitHash != null) { + candidates.add(new DecisionCommitMatchCandidate( + currentApplicationId, + currentApplicationTitle, + null, + repositoryId, + commitHash, + readText(node, REASON_FIELDS).orElse(null), + confidence + )); + } else if (currentApplicationTitle != null && commitHash != null) { + candidates.add(new DecisionCommitMatchCandidate( + null, + currentApplicationTitle, + null, + repositoryId, + commitHash, + readText(node, REASON_FIELDS).orElse(null), + confidence + )); + } + + if (node.isObject()) { + node.fields().forEachRemaining(entry -> collectRecommendationCandidates(entry.getValue(), currentApplicationId, candidates)); + return; + } + + if (node.isArray()) { + node.forEach(child -> collectRecommendationCandidates(child, currentApplicationId, candidates)); + } + } + + // 추천 커밋 배열이 있었는지 확인해 파싱 실패와 추천 없음 상태를 구분 + private boolean hasRecommendedCommits(JsonNode node) { + if (node == null || node.isNull()) { + return false; + } + + if (node.isObject()) { + JsonNode recommendedCommits = node.get("recommended_commits"); + if (recommendedCommits != null && recommendedCommits.isArray() && !recommendedCommits.isEmpty()) { + return true; + } + Iterator iterator = node.elements(); + while (iterator.hasNext()) { + if (hasRecommendedCommits(iterator.next())) { + return true; + } + } + return false; + } + + if (node.isArray()) { + for (JsonNode child : node) { + if (hasRecommendedCommits(child)) { + return true; + } + } + } + + return false; + } + + // 여러 후보 필드명 중 Long으로 읽을 수 있는 값을 찾음 + private Optional readLong(JsonNode node, List fieldNames) { + if (node == null || !node.isObject()) { + return Optional.empty(); + } + + for (String fieldName : fieldNames) { + JsonNode value = node.get(fieldName); + if (value == null || value.isNull()) { + continue; + } + if (value.canConvertToLong()) { + return Optional.of(value.asLong()); + } + if (value.isTextual()) { + try { + return Optional.of(Long.valueOf(value.asText())); + } catch (NumberFormatException ignored) { + continue; + } + } + } + + return Optional.empty(); + } + + // 여러 후보 필드명 중 문자열 값을 찾음 + private Optional readText(JsonNode node, List fieldNames) { + if (node == null || !node.isObject()) { + return Optional.empty(); + } + + for (String fieldName : fieldNames) { + JsonNode value = node.get(fieldName); + if (value != null && value.isValueNode() && !value.asText().isBlank()) { + return Optional.of(value.asText()); + } + } + + return Optional.empty(); + } + + // FastAPI 공통 응답에서 result가 비어 있으면 예외처리 + private T requireResult(FastApiResponse response) { + if (response == null || response.result() == null) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + return response.result(); + } +} diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java index c8594b0..4abfde5 100644 --- a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java +++ b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java @@ -10,6 +10,7 @@ @AllArgsConstructor public enum GitErrorCode implements BaseErrorCode { REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_1", "존재하지 않는 레포지토리입니다."), + REPOSITORY_NOT_IN_TEAM(HttpStatus.BAD_REQUEST, "GIT_400_3", "결정사항이 속한 팀의 레포지토리가 아닙니다."), COMMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_2", "존재하지 않는 커밋입니다."), INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "GIT_400_1", "유효하지 않은 GitHub URL입니다."), GITHUB_TOKEN_NOT_REGISTERED(HttpStatus.BAD_REQUEST, "GIT_400_2", "GitHub Access Token이 등록되지 않았습니다."), diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java index eacc344..9f4008d 100644 --- a/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitConnectionRepository.java @@ -35,6 +35,13 @@ public interface CommitConnectionRepository extends JpaRepository findConnectedCommitIds(@Param("commitIds") List commitIds); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" DELETE FROM CommitConnection cc diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index 59665ab..2b237a1 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -14,6 +14,7 @@ import com.whylog.server.domain.decision.repository.DecisionBaseRepository; import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.decision.service.DecisionCommitMatchService; import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; @@ -75,6 +76,7 @@ public class MeetingAnalysisService { private final MeetingAnalysisRepository meetingAnalysisRepository; private final MeetingMemberRepository meetingMemberRepository; private final MeetingLiveMessageBundleService meetingLiveMessageBundleService; + private final DecisionCommitMatchService decisionCommitMatchService; private final TransactionTemplate transactionTemplate; private final ObjectMapper objectMapper; @@ -243,11 +245,12 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes return replaceApplications(managedMeeting.getId(), decision, applications); }); if (savedApplications == null) { - savedApplications = SavedApplications.empty(); + savedApplications = SavedApplications.empty(null); } log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); sendApplicationEmbeddingsSafely(meeting, response, savedApplications); + matchApplicationCommitsSafely(meeting, savedApplications); } @@ -289,10 +292,10 @@ private SavedApplications replaceApplications(Long meetingId, persistApplicationDetails(savedApplications, validApplications); log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", meetingId, decision.getId(), newApplications.size()); - return new SavedApplications(savedApplications, validApplications); + return new SavedApplications(decision.getId(), savedApplications, validApplications); } - return SavedApplications.empty(); + return SavedApplications.empty(decision.getId()); } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. @@ -373,6 +376,23 @@ private void sendApplicationEmbeddingsSafely(Meeting meeting, } } + // 회의 분석 저장 후 적용사항-커밋 추천 매칭을 자동 실행한다. + private void matchApplicationCommitsSafely(Meeting meeting, SavedApplications savedApplications) { + if (savedApplications.decisionId() == null || savedApplications.savedApplications().isEmpty()) { + log.info("저장된 적용사항이 없어 커밋 추천 매칭을 생략한다: meetingId={}", meeting.getId()); + return; + } + + try { + decisionCommitMatchService.matchApplicationCommits(savedApplications.decisionId()); + log.info("적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", + meeting.getId(), savedApplications.decisionId()); + } catch (Exception exception) { + log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", + meeting.getId(), savedApplications.decisionId(), exception); + } + } + // FastAPI 임베딩 요청을 생성한다. private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, TranscribeApplicationRunResponse runResponse, @@ -565,11 +585,12 @@ private Duration parseDuration(String offset) { } } - private record SavedApplications(List savedApplications, + private record SavedApplications(Long decisionId, + List savedApplications, List sourceApplications) { - private static SavedApplications empty() { - return new SavedApplications(List.of(), List.of()); + private static SavedApplications empty(Long decisionId) { + return new SavedApplications(decisionId, List.of(), List.of()); } } diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java index 26be42f..ae5b358 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java @@ -6,6 +6,7 @@ import com.whylog.server.global.external.fast.FastApiInfo; import com.whylog.server.global.external.fast.dto.FastApiResponse; import com.whylog.server.global.external.fast.dto.request.CommitAnalyzeRequest; +import com.whylog.server.global.external.fast.dto.request.CommitMatchRequest; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -35,7 +36,7 @@ public FastApiResponse getCommitAnalyzeRun(String runId) { ); } - public FastApiResponse matchApplicationCommits(Map request) { + public FastApiResponse matchApplicationCommits(CommitMatchRequest request) { return postJson( FastApiInfo.COMMIT_MATCH, request, diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitMatchRequest.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitMatchRequest.java new file mode 100644 index 0000000..9157fd2 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitMatchRequest.java @@ -0,0 +1,20 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "FastAPI 적용사항-커밋 추천 매칭 요청") +public record CommitMatchRequest( + @Schema(description = "회의 ID", example = "1") + String meetingId, + @Schema(description = "후보로 사용할 레포지토리 ID 목록", example = "[1, 2]") + List repositoryIds, + @Schema(description = "적용사항별 최대 추천 개수", example = "5") + Integer topK +) { +}