Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ dependencies {
implementation platform('software.amazon.awssdk:bom:2.31.69')
implementation 'software.amazon.awssdk:bedrock'
implementation 'software.amazon.awssdk:bedrockruntime'
implementation 'software.amazon.awssdk:bedrockagentruntime'
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:apache-client:2.32.12'
implementation 'software.amazon.awssdk:netty-nio-client:2.32.16'

implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5'
Expand Down
1 change: 1 addition & 0 deletions gradle/gradle-daemon-jvm.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
toolchainVersion=17
21 changes: 4 additions & 17 deletions src/main/java/com/samhap/kokomen/global/config/AwsConfig.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package com.samhap.kokomen.global.config;

import com.samhap.kokomen.global.external.bedrock.BedrockConverseProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient;
import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
@EnableConfigurationProperties(BedrockConverseProperties.class)
public class AwsConfig {

@Bean
Expand All @@ -22,21 +23,7 @@ public BedrockRuntimeClient bedrockRuntimeClient() {
.maxConnections(60)
.connectionAcquisitionTimeout(java.time.Duration.ofSeconds(5))
.connectionTimeout(java.time.Duration.ofSeconds(3))
.socketTimeout(java.time.Duration.ofSeconds(15))
)
.region(Region.AP_NORTHEAST_2)
.build();
}

@Bean
public BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeClient() {
return BedrockAgentRuntimeAsyncClient.builder()
.credentialsProvider(InstanceProfileCredentialsProvider.create())
.httpClientBuilder(NettyNioAsyncHttpClient.builder()
.maxConcurrency(1000)
.connectionAcquisitionTimeout(java.time.Duration.ofSeconds(10))
.connectionTimeout(java.time.Duration.ofSeconds(3))
.readTimeout(java.time.Duration.ofSeconds(180))
.socketTimeout(java.time.Duration.ofSeconds(60))
)
.region(Region.AP_NORTHEAST_2)
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.samhap.kokomen.global.external.bedrock;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.samhap.kokomen.global.exception.ExternalApiException;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import software.amazon.awssdk.services.bedrockruntime.model.ContentBlock;
import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
import software.amazon.awssdk.services.bedrockruntime.model.ConverseResponse;
import software.amazon.awssdk.services.bedrockruntime.model.InferenceConfiguration;
import software.amazon.awssdk.services.bedrockruntime.model.Message;
import software.amazon.awssdk.services.bedrockruntime.model.StopReason;
import software.amazon.awssdk.services.bedrockruntime.model.SystemContentBlock;
import software.amazon.awssdk.services.bedrockruntime.model.ToolConfiguration;
import software.amazon.awssdk.services.bedrockruntime.model.ToolUseBlock;

@Slf4j
@Component
public class BedrockConverseClient {

private final BedrockRuntimeClient bedrockRuntimeClient;
private final BedrockConverseProperties properties;
private final ObjectMapper objectMapper;

public BedrockConverseClient(
BedrockRuntimeClient bedrockRuntimeClient,
BedrockConverseProperties properties,
ObjectMapper objectMapper
) {
this.bedrockRuntimeClient = bedrockRuntimeClient;
this.properties = properties;
this.objectMapper = objectMapper;
}

public ConverseResponse converse(
List<SystemContentBlock> systemMessages,
List<Message> messages,
ToolConfiguration toolConfiguration,
int maxTokens
) {
ConverseRequest request = ConverseRequest.builder()
.modelId(properties.modelId())
.system(systemMessages)
.messages(messages)
.toolConfig(toolConfiguration)
.inferenceConfig(InferenceConfiguration.builder()
.maxTokens(maxTokens)
.temperature(properties.temperature())
.build())
.build();
return bedrockRuntimeClient.converse(request);
}

public ToolUseBlock extractToolUse(ConverseResponse response, String expectedToolName) {
if (response.stopReason() != StopReason.TOOL_USE) {
throw new ExternalApiException(
"Bedrock 응답이 tool_use가 아닙니다. stopReason=" + response.stopReason()
+ ", expected=" + expectedToolName);
}
return response.output().message().content().stream()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

response.output().message().content()를 호출할 때 중간 객체(output, message)가 null일 경우 NullPointerException이 발생할 수 있습니다. stopReasonTOOL_USE이더라도 방어적인 코드를 위해 null 체크를 추가하는 것이 안전합니다.

.filter(content -> content.toolUse() != null)
.map(ContentBlock::toolUse)
.filter(toolUse -> expectedToolName.equals(toolUse.name()))
.findFirst()
.orElseThrow(() -> new ExternalApiException(
"Bedrock 응답에 tool_use 블록이 없습니다. expected=" + expectedToolName));
}

public <T> T parseToolInput(ToolUseBlock toolUse, Class<T> type) {
try {
Object javaObject = DocumentJsonConverter.toJavaObject(toolUse.input());
return objectMapper.convertValue(javaObject, type);
} catch (Exception e) {
throw new ExternalApiException("Bedrock toolUse 파싱 실패: toolName=" + toolUse.name(), e);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.samhap.kokomen.global.external.bedrock;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "aws.bedrock")
public record BedrockConverseProperties(
String modelId,
Integer proceedMaxTokens,
Integer endMaxTokens,
Integer answerFeedbackMaxTokens,
Integer resumeQuestionMaxTokens,
Integer resumeEvaluationMaxTokens,
Float temperature
) {
Comment on lines +5 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# validation 관련 의존성/사용 여부를 읽기 전용으로 확인
fd -i "build.gradle*" | xargs -I{} rg -n "spring-boot-starter-validation|jakarta.validation|hibernate-validator" {}
rg -n "`@ConfigurationProperties`|`@Validated`|jakarta.validation.constraints"

Repository: samhap-soft/kokomen-backend

Length of output: 3433


🏁 Script executed:

# Search for other `@ConfigurationProperties` usage to see if they use validation
rg -A 10 "`@ConfigurationProperties`" --type java | head -60

Repository: samhap-soft/kokomen-backend

Length of output: 1374


🏁 Script executed:

cat -n src/main/java/com/samhap/kokomen/global/external/bedrock/BedrockConverseProperties.java

Repository: samhap-soft/kokomen-backend

Length of output: 641


Bedrock 설정값에 바인딩 단계 검증을 추가하세요.

현재는 modelId 누락, 토큰 음수, temperature 범위 오류가 런타임 호출 시점까지 지연됩니다. 시작 단계에서 실패하도록 제약을 넣는 게 안전합니다. 프로젝트에 spring-boot-starter-validation 의존성이 있으며, 다른 DTO 클래스들에서도 jakarta.validation.constraints를 활발하게 사용하고 있습니다.

🔧 제안 수정안
 import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
 
+@Validated
 `@ConfigurationProperties`(prefix = "aws.bedrock")
 public record BedrockConverseProperties(
-        String modelId,
-        Integer proceedMaxTokens,
-        Integer endMaxTokens,
-        Integer answerFeedbackMaxTokens,
-        Integer resumeQuestionMaxTokens,
-        Integer resumeEvaluationMaxTokens,
-        Float temperature
+        `@NotBlank` String modelId,
+        `@NotNull` `@Positive` Integer proceedMaxTokens,
+        `@NotNull` `@Positive` Integer endMaxTokens,
+        `@NotNull` `@Positive` Integer answerFeedbackMaxTokens,
+        `@NotNull` `@Positive` Integer resumeQuestionMaxTokens,
+        `@NotNull` `@Positive` Integer resumeEvaluationMaxTokens,
+        `@NotNull` `@Min`(0) `@Max`(1) Float temperature
 ) {
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/samhap/kokomen/global/external/bedrock/BedrockConverseProperties.java`
around lines 5 - 14, Add bean validation to the BedrockConverseProperties record
so invalid config fails at startup: annotate the record with `@Validated` and add
jakarta.validation constraints to fields — mark modelId as `@NotBlank`, mark
proceedMaxTokens, endMaxTokens, answerFeedbackMaxTokens,
resumeQuestionMaxTokens, resumeEvaluationMaxTokens as `@NotNull` and
`@PositiveOrZero` (or `@Min`(0)), and constrain temperature with `@NotNull` and
`@DecimalMin`("0.0") `@DecimalMax`("1.0"); ensure imports use jakarta.validation.*
and that the class remains annotated with `@ConfigurationProperties`(prefix =
"aws.bedrock") so Spring validates the bound values on startup.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.samhap.kokomen.global.external.bedrock;

import com.samhap.kokomen.global.exception.ExternalApiException;
import java.util.LinkedHashMap;
import java.util.Map;
import software.amazon.awssdk.core.document.Document;

public final class DocumentJsonConverter {

private DocumentJsonConverter() {
}

public static Object toJavaObject(Document document) {
if (document == null || document.isNull()) {
return null;
}
if (document.isBoolean()) {
return document.asBoolean();
}
if (document.isString()) {
return document.asString();
}
if (document.isNumber()) {
return document.asNumber().bigDecimalValue();
}
if (document.isList()) {
return document.asList().stream()
.map(DocumentJsonConverter::toJavaObject)
.toList();
}
if (document.isMap()) {
Map<String, Object> map = new LinkedHashMap<>();
document.asMap().forEach((key, value) -> map.put(key, toJavaObject(value)));
return map;
}
throw new ExternalApiException("알 수 없는 Document 타입입니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.samhap.kokomen.interview.external;

import com.samhap.kokomen.answer.domain.AnswerRank;
import com.samhap.kokomen.global.annotation.ExecutionTimer;
import com.samhap.kokomen.global.exception.ExternalApiException;
import com.samhap.kokomen.global.external.bedrock.BedrockConverseClient;
import com.samhap.kokomen.global.external.bedrock.BedrockConverseProperties;
import com.samhap.kokomen.interview.external.dto.request.InterviewBedrockRequestFactory;
import com.samhap.kokomen.interview.external.dto.response.AnswerFeedbackOnlyResponse;
import com.samhap.kokomen.interview.tool.QuestionAndAnswers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.bedrockruntime.model.ConverseResponse;
import software.amazon.awssdk.services.bedrockruntime.model.ToolUseBlock;

@Slf4j
@ExecutionTimer
@Component
public class AnswerFeedbackBedrockClient {

private final BedrockConverseClient converseClient;
private final BedrockConverseProperties properties;

public AnswerFeedbackBedrockClient(
BedrockConverseClient converseClient,
BedrockConverseProperties properties
) {
this.converseClient = converseClient;
this.properties = properties;
}

public String requestAnswerFeedback(QuestionAndAnswers questionAndAnswers, AnswerRank curAnswerRank) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createAnswerFeedbackSystem(),
InterviewBedrockRequestFactory.createAnswerFeedbackMessages(questionAndAnswers, curAnswerRank),
InterviewBedrockRequestFactory.createAnswerFeedbackToolConfig(),
properties.answerFeedbackMaxTokens());
ToolUseBlock toolUse = converseClient.extractToolUse(response, InterviewBedrockRequestFactory.ANSWER_FEEDBACK_TOOL_NAME);
AnswerFeedbackOnlyResponse parsed = converseClient.parseToolInput(toolUse, AnswerFeedbackOnlyResponse.class);
if (parsed.feedback() == null || parsed.feedback().isBlank()) {
throw new ExternalApiException("Bedrock 답변 피드백 응답이 비어있습니다.");
}
return parsed.feedback();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.samhap.kokomen.interview.external;

import com.samhap.kokomen.global.annotation.ExecutionTimer;
import com.samhap.kokomen.global.external.bedrock.BedrockConverseClient;
import com.samhap.kokomen.global.external.bedrock.BedrockConverseProperties;
import com.samhap.kokomen.interview.external.dto.request.InterviewBedrockRequestFactory;
import com.samhap.kokomen.interview.external.dto.response.BedrockConverseResponse;
import com.samhap.kokomen.interview.tool.QuestionAndAnswers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.bedrockruntime.model.ConverseResponse;
import software.amazon.awssdk.services.bedrockruntime.model.ToolUseBlock;

@Slf4j
@ExecutionTimer
@Component
public class InterviewProceedBedrockClient {

private final BedrockConverseClient converseClient;
private final BedrockConverseProperties properties;

public InterviewProceedBedrockClient(
BedrockConverseClient converseClient,
BedrockConverseProperties properties
) {
this.converseClient = converseClient;
this.properties = properties;
}

public BedrockConverseResponse requestToBedrock(QuestionAndAnswers questionAndAnswers) {
if (questionAndAnswers.isProceedRequest()) {
return requestProceed(questionAndAnswers);
}
return requestEnd(questionAndAnswers);
}

private BedrockConverseResponse requestProceed(QuestionAndAnswers questionAndAnswers) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createProceedSystem(),
InterviewBedrockRequestFactory.createProceedMessages(questionAndAnswers),
InterviewBedrockRequestFactory.createProceedToolConfig(),
properties.proceedMaxTokens());
ToolUseBlock toolUse = converseClient.extractToolUse(response, InterviewBedrockRequestFactory.PROCEED_TOOL_NAME);
return new BedrockConverseResponse(toolUse.input());
}

private BedrockConverseResponse requestEnd(QuestionAndAnswers questionAndAnswers) {
ConverseResponse response = converseClient.converse(
InterviewBedrockRequestFactory.createEndSystem(),
InterviewBedrockRequestFactory.createProceedMessages(questionAndAnswers),
InterviewBedrockRequestFactory.createEndToolConfig(),
properties.endMaxTokens());
ToolUseBlock toolUse = converseClient.extractToolUse(response, InterviewBedrockRequestFactory.END_TOOL_NAME);
return new BedrockConverseResponse(toolUse.input());
}
}
Loading
Loading