Skip to content
Open
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
5 changes: 5 additions & 0 deletions stock-diary/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ dependencies {
// AWS SDK S3 (MinIO compatible)
implementation 'software.amazon.awssdk:s3:2.20.0'

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'

compileOnly 'org.projectlombok:lombok'

runtimeOnly 'com.h2database:h2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ogd.stockdiary.application.config;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.querydsl.jpa.impl.JPAQueryFactory;

@Configuration
public class QueryDslConfig {

@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.ogd.stockdiary.application.report.repository;

import static com.ogd.stockdiary.domain.image.entity.QPrincipleCheckImage.principleCheckImage;
import static com.ogd.stockdiary.domain.investmentprinciple.entity.QInvestmentPrinciple.investmentPrinciple;
import static com.ogd.stockdiary.domain.principlecheck.entity.QPrincipleCheck.principleCheck;
import static com.ogd.stockdiary.domain.principlecheck.entity.QPrincipleCheckLink.principleCheckLink;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.stereotype.Repository;

import com.ogd.stockdiary.domain.fileclient.port.out.FileClientPort;
import com.ogd.stockdiary.domain.report.port.out.PrincipleCheckPort;
import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class PrincipleCheckQueryDslAdapter implements PrincipleCheckPort {

private final JPAQueryFactory queryFactory;
private final FileClientPort fileClientPort;

@Override
public List<PrincipleCheckData> findByRetrospectionId(Long retrospectionId) {
// 1. PrincipleCheck 기본 데이터 조회
List<PrincipleCheckDto> checks = queryFactory
.select(Projections.constructor(PrincipleCheckDto.class,
principleCheck.id,
investmentPrinciple.principle,
principleCheck.status.stringValue(),
principleCheck.reason))
.from(principleCheck)
.join(principleCheck.principle, investmentPrinciple)
.where(principleCheck.retrospection.id.eq(retrospectionId))
.fetch();

if (checks.isEmpty()) {
return List.of();
}

// 2. checkId 리스트 추출
List<Long> checkIds = checks.stream()
.map(PrincipleCheckDto::checkId)
.collect(Collectors.toList());

// 3. 이미지 다운로드 URL 맵 조회 (checkId -> List<downloadUrl>)
Map<Long, List<String>> imageUrlsMap = getImageUrlsMap(checkIds);

// 4. 링크 맵 조회 (checkId -> List<linkUrl>)
Map<Long, List<String>> linksMap = getLinksMap(checkIds);

// 5. VO로 변환
return checks.stream()
.map(dto -> new PrincipleCheckData(
dto.principleName,
dto.status,
dto.reason,
imageUrlsMap.getOrDefault(dto.checkId, List.of()),
linksMap.getOrDefault(dto.checkId, List.of())))
.collect(Collectors.toList());
}

/**
* 이미지 다운로드 URL 맵 조회
* objectKey를 조회한 후 download pre-signed URL로 변환
*/
private Map<Long, List<String>> getImageUrlsMap(List<Long> checkIds) {
List<ImageKeyDto> imageKeyDtos = queryFactory
.select(Projections.constructor(ImageKeyDto.class,
principleCheckImage.principleCheck.id,
principleCheckImage.image.objectKey))
.from(principleCheckImage)
.where(principleCheckImage.principleCheck.id.in(checkIds))
.fetch();

// objectKey를 download URL로 변환
return imageKeyDtos.stream()
.collect(Collectors.groupingBy(
ImageKeyDto::checkId,
Collectors.mapping(
dto -> fileClientPort.getDownloadPreSignedUrl(dto.objectKey, 3600),
Collectors.toList())));
}

/**
* 링크 맵 조회
*/
private Map<Long, List<String>> getLinksMap(List<Long> checkIds) {
List<LinkDto> linkDtos = queryFactory
.select(Projections.constructor(LinkDto.class,
principleCheckLink.principleCheck.id,
principleCheckLink.linkUrl))
.from(principleCheckLink)
.where(principleCheckLink.principleCheck.id.in(checkIds))
.fetch();

return linkDtos.stream()
.collect(Collectors.groupingBy(
LinkDto::checkId,
Collectors.mapping(LinkDto::linkUrl, Collectors.toList())));
}

/**
* 중간 DTO - PrincipleCheck 데이터
*/
private record PrincipleCheckDto(
Long checkId,
String principleName,
String status,
String reason) {
}

/**
* 중간 DTO - 이미지 objectKey (download URL 변환용)
*/
private record ImageKeyDto(
Long checkId,
String objectKey) {
}

/**
* 중간 DTO - 링크
*/
private record LinkDto(
Long checkId,
String linkUrl) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
import com.ogd.stockdiary.domain.report.port.in.CreateFeedbackCommand;
import com.ogd.stockdiary.domain.report.port.in.CreateFeedbackUseCase;
import com.ogd.stockdiary.domain.report.port.out.FeedbackRepository;
import com.ogd.stockdiary.domain.report.port.out.PrincipleCheckPort;
import com.ogd.stockdiary.domain.report.port.out.ReportPromptLoader;
import com.ogd.stockdiary.domain.report.port.out.RetrospectionForReportRepository;
import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData;
import com.ogd.stockdiary.domain.retrospection.entity.Order;
import com.ogd.stockdiary.domain.retrospection.entity.Retrospection;
import com.ogd.stockdiary.domain.retrospection.port.out.RetrospectionRepository;
Expand All @@ -37,6 +39,7 @@ public class ReportService implements CreateFeedbackUseCase {
private final RetrospectionForReportRepository retrospectionForReportRepository;
private final RetrospectionRepository retrospectionRepository;
private final FeedbackRepository feedbackRepository;
private final PrincipleCheckPort principleCheckPort;
private final ReportPromptLoader reportPromptLoader;
private final ChatModel chatModel;
private final ObjectMapper objectMapper;
Expand All @@ -56,17 +59,32 @@ public Feedback createFeedbackUseCase(CreateFeedbackCommand command)
Order order = retrospectionForReport.getOrder();
String content = retrospectionForReport.getContent();

// 투자원칙 체크 데이터 조회
List<PrincipleCheckData> principleChecks = principleCheckPort
.findByRetrospectionId(command.retrospectionId());

// 투자원칙 체크 데이터를 프롬프트 형식으로 변환
String principleChecksText = PrincipleCheckData.toPromptFormat(principleChecks);

String userText = """
Please analyze the symbol {symbol} in the {market} market based on the order: {order}.
This is user message : {content}.

Investment Principles Checked:
{principleChecks}
""";

// 시스템 메시지를 로더에서 불러오기
Message systemMessage = new SystemMessage(reportPromptLoader.getPrompt());

PromptTemplate promptTemplate = new PromptTemplate(userText);

Map<String, Object> variables = Map.of("symbol", symbol, "market", market, "order", order, "content", content);
Map<String, Object> variables = Map.of(
"symbol", symbol,
"market", market,
"order", order,
"content", content,
"principleChecks", principleChecksText);

// 플레이스 홀더 넣은 유저 메시지 구성
Message userMessage = promptTemplate.createMessage(variables);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ogd.stockdiary.domain.report.port.out;

import java.util.List;

import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData;

/**
* 투자원칙 체크 데이터 조회 Port
* Report 도메인이 다른 도메인에 의존하지 않도록 추상화
*/
public interface PrincipleCheckPort {

/**
* 특정 회고에 대한 모든 투자원칙 체크 데이터 조회
*
* @param retrospectionId
* 회고 ID
* @return 투자원칙 체크 데이터 목록
*/
List<PrincipleCheckData> findByRetrospectionId(Long retrospectionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.ogd.stockdiary.domain.report.vo;

import java.util.List;
import java.util.stream.Collectors;

/**
* Report 도메인에서 사용하는 투자원칙 체크 데이터
* AI에 전달할 정보만 포함 (읽기 전용 VO)
*/
public record PrincipleCheckData(
String principleName, // 투자원칙 내용
String status, // 체크 상태 (CHECKED, NOT_CHECKED, VIOLATED)
String reason, // 체크 이유/설명
List<String> imageUrls, // 관련 이미지 다운로드 URL 목록
List<String> links // 관련 링크 목록
) {

/**
* 빈 데이터 생성 헬퍼
*/
public static PrincipleCheckData empty() {
return new PrincipleCheckData("", "", "", List.of(), List.of());
}

/**
* AI 프롬프트용 포맷팅
*/
public String toPromptFormat() {
StringBuilder sb = new StringBuilder();
sb.append("- 원칙: ").append(principleName).append("\n");
sb.append(" 준수 여부: ").append(status).append("\n");

if (reason != null && !reason.isBlank()) {
sb.append(" 사유: ").append(reason).append("\n");
}

if (!imageUrls.isEmpty()) {
sb.append(" 첨부 이미지:\n");
imageUrls.forEach(url -> sb.append(" - ").append(url).append("\n"));
}

if (!links.isEmpty()) {
sb.append(" 참고 링크:\n");
links.forEach(link -> sb.append(" - ").append(link).append("\n"));
}

return sb.toString();
}

/**
* 여러 체크 데이터를 프롬프트 형식으로 변환
*/
public static String toPromptFormat(List<PrincipleCheckData> checks) {
if (checks == null || checks.isEmpty()) {
return "투자원칙 체크 데이터 없음";
}

return checks.stream()
.map(PrincipleCheckData::toPromptFormat)
.collect(Collectors.joining("\n"));
}
}