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
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateAnswerImageUploadUrlResponse;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.ImageStatusResponse;
import com.devkor.ifive.nadab.domain.dailyreport.application.helper.DailyReportModelSelector;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.ImageStatus;
import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties.ModelCandidate;
import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient;
import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion;
Expand Down Expand Up @@ -48,6 +50,7 @@ public class DailyReportService {
private final DailyReportTxService dailyReportTxService;
private final ProfileImageService profileImageService;

private final DailyReportModelSelector dailyReportModelSelector;
private final DailyReportLlmClient dailyReportLlmClient;
private final ReportGenerationLogRecorder reportGenerationLogRecorder;

Expand Down Expand Up @@ -88,19 +91,20 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq
PrepareDailyResultDto prep = dailyReportTxService.prepareDaily(user, question, request.answer(), isDayPassed, request.objectKey());

AnswerEntry answerEntry = prep.entry();
ModelCandidate modelCandidate = dailyReportModelSelector.select();
Long generationLogId = reportGenerationLogRecorder.start(
userId,
ReportGenerationType.DAILY,
prep.reportId(),
ReportGenerationStep.DAILY_GENERATE,
LlmProvider.OPENAI,
dailyReportLlmClient.model()
modelCandidate.getModel()
);

AiDailyReportResultDto dto;
try {
LlmGenerationResult<AiDailyReportResultDto> generationResult =
dailyReportLlmClient.generate(question.getQuestionText(), answerEntry);
dailyReportLlmClient.generate(question.getQuestionText(), answerEntry, modelCandidate);
dto = generationResult.content();
LlmTokenUsage tokenUsage = generationResult.tokenUsage();
reportGenerationLogRecorder.recordTokenUsage(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.devkor.ifive.nadab.domain.dailyreport.application.helper;

import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties;
import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties.ModelCandidate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.ThreadLocalRandom;
import java.util.function.IntUnaryOperator;

@Component
public class DailyReportModelSelector {

private static final int TOTAL_WEIGHT = 100;

private final DailyReportLlmProperties properties;
private final IntUnaryOperator randomValueGenerator;

@Autowired
public DailyReportModelSelector(DailyReportLlmProperties properties) {
this(properties, bound -> ThreadLocalRandom.current().nextInt(bound));
}

DailyReportModelSelector(
DailyReportLlmProperties properties,
IntUnaryOperator randomValueGenerator
) {
this.properties = properties;
this.randomValueGenerator = randomValueGenerator;
}

public ModelCandidate select() {
int randomValue = randomValueGenerator.applyAsInt(TOTAL_WEIGHT);
int cumulativeWeight = 0;

for (ModelCandidate candidate : properties.getCandidates()) {
cumulativeWeight += candidate.getWeight();
if (randomValue < cumulativeWeight) {
return candidate;
}
}

throw new IllegalStateException("Failed to select a DailyReport LLM model candidate");
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,86 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.properties;

import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;

@Component
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "daily-report.llm")
public class DailyReportLlmProperties {

@NotBlank
private String model = "gpt-4o-mini";
@Valid
@NotEmpty
private List<@NotNull ModelCandidate> candidates = new ArrayList<>();

@AssertTrue(message = "daily-report.llm.candidates weights must total 100")
public boolean isCandidateWeightTotalValid() {
if (candidates == null || candidates.isEmpty()) {
return true;
}

return candidates.stream()
.filter(Objects::nonNull)
.mapToInt(ModelCandidate::getWeight)
.sum() == 100;
}

@AssertTrue(message = "daily-report.llm.candidates models must be unique")
public boolean isCandidateModelUnique() {
if (candidates == null || candidates.isEmpty()) {
return true;
}

List<String> models = candidates.stream()
.filter(Objects::nonNull)
.map(ModelCandidate::getModel)
.filter(Objects::nonNull)
.toList();

@DecimalMin("0.0")
@DecimalMax("2.0")
private double temperature = 0.3;
return new HashSet<>(models).size() == models.size();
}

@Getter
@Setter
public static class ModelCandidate {

@NotBlank
private String model;

@Min(1)
private int maxOutputTokens = 512;
@Min(1)
@Max(100)
private int weight;

@NotNull
private TokenLimitParameter tokenLimitParameter = TokenLimitParameter.MAX_TOKENS;
@DecimalMin("0.0")
@DecimalMax("2.0")
private double temperature;

private String reasoningEffort;
@Min(1)
private int maxOutputTokens;

@NotNull
private TokenLimitParameter tokenLimitParameter;

private String reasoningEffort;
}

public enum TokenLimitParameter {
MAX_TOKENS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.LlmDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties;
import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties.ModelCandidate;
import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader;
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
Expand Down Expand Up @@ -37,11 +37,14 @@ public class DailyReportLlmClient {
private final ObjectMapper objectMapper;
private final LlmRouter llmRouter;
private final ProfileImageUrlBuilder profileImageUrlBuilder;
private final DailyReportLlmProperties properties;

private final LlmProvider provider = LlmProvider.OPENAI;

public LlmGenerationResult<AiDailyReportResultDto> generate(String question, AnswerEntry answerEntry) {
public LlmGenerationResult<AiDailyReportResultDto> generate(
String question,
AnswerEntry answerEntry,
ModelCandidate modelCandidate
) {

String answer = answerEntry.getContent();

Expand All @@ -55,7 +58,7 @@ public LlmGenerationResult<AiDailyReportResultDto> generate(String question, Ans

ChatClient chatClient = llmRouter.route(provider);

OpenAiChatOptions options = buildOptions();
OpenAiChatOptions options = buildOptions(modelCandidate);

UserMessage userMessage = buildUserMessage(prompt, withImagePrompt,answerEntry);

Expand Down Expand Up @@ -103,22 +106,18 @@ public LlmGenerationResult<AiDailyReportResultDto> generate(String question, Ans
}
}

public String model() {
return properties.getModel();
}

private OpenAiChatOptions buildOptions() {
private OpenAiChatOptions buildOptions(ModelCandidate modelCandidate) {
var builder = OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature());
.model(modelCandidate.getModel())
.temperature(modelCandidate.getTemperature());

switch (properties.getTokenLimitParameter()) {
case MAX_TOKENS -> builder.maxTokens(properties.getMaxOutputTokens());
case MAX_COMPLETION_TOKENS -> builder.maxCompletionTokens(properties.getMaxOutputTokens());
switch (modelCandidate.getTokenLimitParameter()) {
case MAX_TOKENS -> builder.maxTokens(modelCandidate.getMaxOutputTokens());
case MAX_COMPLETION_TOKENS -> builder.maxCompletionTokens(modelCandidate.getMaxOutputTokens());
}

if (!isBlank(properties.getReasoningEffort())) {
builder.reasoningEffort(properties.getReasoningEffort());
if (!isBlank(modelCandidate.getReasoningEffort())) {
builder.reasoningEffort(modelCandidate.getReasoningEffort());
}

return builder.build();
Expand Down
8 changes: 0 additions & 8 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ spring:
ddl-auto: validate
show-sql: false

daily-report:
llm:
model: gpt-5.6-luna
temperature: 1.0
max-output-tokens: 512
token-limit-parameter: MAX_COMPLETION_TOKENS
reasoning-effort: none

springdoc:
api-docs:
enabled: true
Expand Down
16 changes: 12 additions & 4 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ api_prefix: /api/v1

daily-report:
llm:
model: gpt-4o-mini
temperature: 0.3
max-output-tokens: 512
token-limit-parameter: MAX_TOKENS
candidates:
- model: gpt-4o-mini
weight: 50
temperature: 0.3
max-output-tokens: 512
token-limit-parameter: MAX_TOKENS
- model: gpt-5.6-luna
weight: 50
temperature: 1.0
max-output-tokens: 512
token-limit-parameter: MAX_COMPLETION_TOKENS
reasoning-effort: none

ask-chat:
answer:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
import com.devkor.ifive.nadab.domain.dailyreport.application.helper.DailyReportModelSelector;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.Emotion;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode;
import com.devkor.ifive.nadab.domain.dailyreport.core.properties.DailyReportLlmProperties.ModelCandidate;
import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient;
import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion;
Expand Down Expand Up @@ -61,6 +63,9 @@ class DailyReportServiceTest {
@Mock
ProfileImageService profileImageService;

@Mock
DailyReportModelSelector dailyReportModelSelector;

@Mock
DailyReportLlmClient dailyReportLlmClient;

Expand All @@ -80,6 +85,7 @@ void setUp() {
userDailyQuestionRepository,
dailyReportTxService,
profileImageService,
dailyReportModelSelector,
dailyReportLlmClient,
reportGenerationLogRecorder,
profileImageUrlBuilder
Expand All @@ -99,14 +105,15 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log(
AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", today, null);
AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT");
Emotion emotion = emotion(EmotionCode.ACHIEVEMENT);
ModelCandidate modelCandidate = modelCandidate("gpt-5.6-luna");

when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question));
when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class)))
.thenReturn(Optional.of(UserDailyQuestion.create(user, today, question)));
when(dailyReportTxService.prepareDaily(user, question, "answer", false, null))
.thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId));
when(dailyReportLlmClient.model()).thenReturn("gpt-5.6-luna");
when(dailyReportModelSelector.select()).thenReturn(modelCandidate);
when(reportGenerationLogRecorder.start(
userId,
ReportGenerationType.DAILY,
Expand All @@ -115,7 +122,7 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log(
LlmProvider.OPENAI,
"gpt-5.6-luna"
)).thenReturn(generationLogId);
when(dailyReportLlmClient.generate("question", answerEntry))
when(dailyReportLlmClient.generate("question", answerEntry, modelCandidate))
.thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L)));
when(dailyReportTxService.confirmDailyAndReward(
any(PrepareDailyResultDto.class),
Expand All @@ -133,6 +140,8 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log(
assertThat(response.reportId()).isEqualTo(reportId);
assertThat(response.content()).isEqualTo("message");
assertThat(response.balanceAfter()).isEqualTo(110L);
verify(dailyReportModelSelector).select();
verify(dailyReportLlmClient).generate("question", answerEntry, modelCandidate);

InOrder inOrder = inOrder(reportGenerationLogRecorder);
inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, 100L, 50L, 150L, null);
Expand All @@ -151,16 +160,18 @@ void generate_daily_report_records_null_token_usage_when_usage_is_empty() {
AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", today, null);
AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT");
Emotion emotion = emotion(EmotionCode.ACHIEVEMENT);
ModelCandidate modelCandidate = modelCandidate("gpt-4o-mini");

when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question));
when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class)))
.thenReturn(Optional.of(UserDailyQuestion.create(user, today, question)));
when(dailyReportTxService.prepareDaily(user, question, "answer", false, null))
.thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId));
when(dailyReportModelSelector.select()).thenReturn(modelCandidate);
when(reportGenerationLogRecorder.start(any(), any(), any(), any(), any(), any()))
.thenReturn(generationLogId);
when(dailyReportLlmClient.generate("question", answerEntry))
when(dailyReportLlmClient.generate("question", answerEntry, modelCandidate))
.thenReturn(new LlmGenerationResult<>(aiResult, LlmTokenUsage.empty()));
when(dailyReportTxService.confirmDailyAndReward(any(), eq(aiResult), eq(null)))
.thenReturn(new ConfirmDailyAndRewardDto(emotion, 110L));
Expand Down Expand Up @@ -190,4 +201,10 @@ private Emotion emotion(EmotionCode code) {
when(emotion.getCode()).thenReturn(code);
return emotion;
}

private ModelCandidate modelCandidate(String model) {
ModelCandidate modelCandidate = new ModelCandidate();
modelCandidate.setModel(model);
return modelCandidate;
}
}
Loading
Loading