Skip to content

Commit

Permalink
Merge pull request #241 from prgrms-web-devcourse-final-project/develop
Browse files Browse the repository at this point in the history
main 브랜치 업데이트
  • Loading branch information
Dom1046 authored Dec 26, 2024
2 parents 71d4c7c + 44f9e89 commit d0bcba5
Show file tree
Hide file tree
Showing 31 changed files with 1,118 additions and 533 deletions.
35 changes: 34 additions & 1 deletion .github/workflows/CICD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:


# 생성된 파일들을 아티팩트로 업로드
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: application.properties
path: ./src/main/resources/application.properties
Expand All @@ -50,6 +50,39 @@ jobs:
artifact=$(ls ./build/libs/*.jar | head -n 1)
echo "artifact=$artifact" >> $GITHUB_ENV
# 9. SSH 키 설정
- name: Set up SSH
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
# GitHub Secrets에 저장된 EC2 SSH 개인 키를 SSH 에이전트에 로드

# 10. GCP Credentials 디코딩 및 EC2로 전송
- name: Decode GCP Credentials
run: echo "${{ secrets.GCP_CREDENTIALS }}" | base64 --decode > gcp-key.json
# GitHub Secrets에 저장된 Base64 인코딩된 GCP JSON Key를 디코딩하여 로컬에 저장

- name: Copy GCP Credentials to EC2
run: scp -o StrictHostKeyChecking=no gcp-key.json ${{ secrets.EC2_USER }}@${{ secrets.EC2_IP }}:/home/${{ secrets.EC2_USER }}/gcp-key.json
# 디코딩된 GCP JSON Key 파일을 EC2 서버로 전송
# YOUR_EC2_IP -> GitHub Secrets에 저장된 EC2 IP 주소 (${ secrets.EC2_IP })
# ec2-user -> GitHub Secrets에 저장된 EC2 SSH 사용자 이름 (${ secrets.EC2_USER })

# 11. EC2에서 GCP Credentials 설정
- name: Set up GCP Credentials on EC2
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_IP }} << 'EOF'
mkdir -p ~/.gcp
mv /home/${{ secrets.EC2_USER }}/gcp-key.json ~/.gcp/key.json
chmod 600 ~/.gcp/key.json
export GOOGLE_APPLICATION_CREDENTIALS=~/.gcp/key.json
# 환경 변수 설정을 영구적으로 추가
echo 'export GOOGLE_APPLICATION_CREDENTIALS=~/.gcp/key.json' >> ~/.bash_profile
EOF
# EC2 서버에서 GCP Credentials 설정
# YOUR_EC2_IP -> GitHub Secrets에 저장된 EC2 IP 주소 (${ secrets.EC2_IP })
# ec2-user -> GitHub Secrets에 저장된 EC2 SSH 사용자 이름 (${ secrets.EC2_USER })

# 빈스토크 배포
- name: Deploy to Elastic Beanstalk
uses: einaregilsson/beanstalk-deploy@v20
Expand Down
55 changes: 45 additions & 10 deletions .github/workflows/CICDdevelop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,88 @@ on:
branches:
- develop


jobs:
build:
runs-on: ubuntu-latest

steps:
# 1. 코드 체크아웃
- uses: actions/checkout@v3

# 2. Java 설정
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'

# application.yml 파일 생성
# 3. application.properties 파일 생성 및 설정
- run: touch ./src/main/resources/application.properties
- run: echo "${{secrets.APPLICATION}}" > ./src/main/resources/application.properties
- run: echo "${{secrets.APPLICATION2}}" > ./src/main/resources/application.properties
- run: echo "spring.cloud.aws.credentials.access-key=${{secrets.AWS_ACTION_ACCESS_KEY}}" >> ./src/main/resources/application.properties
- run: echo "spring.cloud.aws.credentials.secret-key=${{secrets.AWS_ACTION_SECRET_ACCESS_KEY}}" >> ./src/main/resources/application.properties


# 생성된 파일들을 아티팩트로 업로드
- uses: actions/upload-artifact@v3
# 4. 생성된 파일들을 아티팩트로 업로드
- uses: actions/upload-artifact@v4
with:
name: application.properties
path: ./src/main/resources/application.properties

# 5. Gradlew 실행 권한 부여
- name: Grant execute permission for gradlew
run: chmod +x gradlew

# 6. Gradle 빌드 실행
- name: Build with Gradle
run: ./gradlew clean build -x test

# 7. 현재 시간 가져오기
- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
with:
format: YYYY-MM-DDTHH-mm-ss
utcOffset: "+09:00"

# 배포용 패키지 경로 설정
# 8. 배포용 패키지 경로 설정
- name: Set artifact path
run: |
artifact=$(ls ./build/libs/*.jar | head -n 1)
echo "artifact=$artifact" >> $GITHUB_ENV
# 빈스토크 배포
# 9. SSH 키 설정
- name: Set up SSH
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.EC2_SSH_KEY_TWO }}
# GitHub Secrets에 저장된 EC2 SSH 개인 키를 SSH 에이전트에 로드

# 10. GCP Credentials 디코딩 및 EC2로 전송
- name: Decode GCP Credentials
run: echo "${{ secrets.GCP_CREDENTIALS }}" | base64 --decode > gcp-key.json
# GitHub Secrets에 저장된 Base64 인코딩된 GCP JSON Key를 디코딩하여 로컬에 저장

- name: Copy GCP Credentials to EC2
run: scp -o StrictHostKeyChecking=no gcp-key.json ${{ secrets.EC2_USER }}@${{ secrets.EC2_IP_TWO }}:/home/${{ secrets.EC2_USER }}/gcp-key.json
# 디코딩된 GCP JSON Key 파일을 EC2 서버로 전송
# YOUR_EC2_IP -> GitHub Secrets에 저장된 EC2 IP 주소 (${ secrets.EC2_IP })
# ec2-user -> GitHub Secrets에 저장된 EC2 SSH 사용자 이름 (${ secrets.EC2_USER })

# 11. EC2에서 GCP Credentials 설정
- name: Set up GCP Credentials on EC2
run: |
ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_IP_TWO }} << 'EOF'
mkdir -p ~/.gcp
mv /home/${{ secrets.EC2_USER }}/gcp-key.json ~/.gcp/key.json
chmod 600 ~/.gcp/key.json
export GOOGLE_APPLICATION_CREDENTIALS=~/.gcp/key.json
# 환경 변수 설정을 영구적으로 추가
echo 'export GOOGLE_APPLICATION_CREDENTIALS=~/.gcp/key.json' >> ~/.bash_profile
EOF
# EC2 서버에서 GCP Credentials 설정
# YOUR_EC2_IP -> GitHub Secrets에 저장된 EC2 IP 주소 (${ secrets.EC2_IP })
# ec2-user -> GitHub Secrets에 저장된 EC2 SSH 사용자 이름 (${ secrets.EC2_USER })

# 12. Elastic Beanstalk에 배포
- name: Deploy to Elastic Beanstalk
uses: einaregilsson/beanstalk-deploy@v20
with:
Expand All @@ -60,5 +96,4 @@ jobs:
environment_name: Mallangs2-two-env
version_label: github-action-${{ steps.current-time.outputs.time }}
region: ap-northeast-2
deployment_package: ${{ env.artifact }}

deployment_package: ${{ env.artifact }}
11 changes: 10 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ configurations {

repositories {
mavenCentral()
maven {url 'https://repo.spring.io/snapshot'} //스냅샷 저장소 추가
}

dependencies {
Expand Down Expand Up @@ -70,7 +71,6 @@ dependencies {
implementation 'org.webjars:stomp-websocket:2.3.3-1'

// 지리 데이터
implementation 'org.hibernate.orm:hibernate-spatial:6.5.3.Final'
implementation 'com.querydsl:querydsl-spatial'

//테스트
Expand Down Expand Up @@ -106,6 +106,11 @@ dependencies {
implementation 'io.awspring.cloud:spring-cloud-aws-starter:3.1.1'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.777'

//AI prompt
implementation 'org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter:1.0.0-SNAPSHOT'

//jackson 날짜변환 위한 의존성
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

}
jar {
Expand All @@ -114,3 +119,7 @@ jar {
tasks.named('test') {
useJUnitPlatform()
}
//긴 클래스 패스 길이 해결
tasks.withType(JavaExec).configureEach {
classpath = files(sourceSets.main.runtimeClasspath.files)
}
164 changes: 164 additions & 0 deletions src/main/java/com/mallangs/domain/ai/AIPromptController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.mallangs.domain.ai;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.mallangs.domain.ai.dto.SightAIResponse;
import com.mallangs.domain.article.dto.response.ArticleResponse;
import com.mallangs.domain.article.dto.response.LostResponse;
import com.mallangs.domain.article.service.ArticleService;
import com.mallangs.domain.board.dto.response.SightingListResponse;
import com.mallangs.domain.board.service.BoardService;
import com.mallangs.domain.member.entity.Member;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@Log4j2
@RestController
@RequestMapping("/api/v1/ai")
@RequiredArgsConstructor
public class AIPromptController {

private final VertexAiGeminiChatModel vertexAiGeminiChatModel;
private final ArticleService articleService;
private final BoardService boardService;

// AI로 비슷한 목격게시물 조회
// 관리자 전부 조회 가능
// 회원 visible + 자신의 글 조회 가능
// 비회원 mapVisible 만 조회 가능
@Operation(summary = "AI로 비슷한 목격게시물을 조회", description = "AI로 실종글타래와 비슷한 목격게시물을 조회합니다.")
@GetMapping("/{articleId}")
public ResponseEntity<List<SightAIResponse>> getArticleByArticleIdByAI(
@Parameter(description = "조회할 글타래 ID", required = true) @PathVariable Long articleId) {

String memberRole;
Long memberId;

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof Member member) {
memberRole = member.getMemberRole().name();
memberId = member.getMemberId();
} else {
memberRole = "ROLE_GUEST";
memberId = -1L;
}
log.info("role: {} memberId: {}", memberRole, memberId);

//단전조회, 실종글타래 조회
ArticleResponse articleResponse = articleService.getArticleById(articleId, memberRole,
memberId);

//질문 제작
StringBuilder question = new StringBuilder();

// JSON 형식에 대한 예시 설명
question.append("실종 동물과 유사한 목격 정보를 여러 개 찾습니다. " +
"반드시 JSON 배열 형식으로 응답해주세요. 예시는 다음과 같습니다:\n");

question.append("[\n");
question.append(" {\n");
question.append(" \"sightArticleId\": 12345,\n");
question.append(" \"percentage\": 85.52,\n");
question.append(" \"findSpot\": \"서울특별시 강남구 역삼동\",\n");
question.append(" \"sightedAt\": \"2024-12-24\",\n");
question.append(" \"breed\": \"Labrador\",\n");
question.append(" \"color\": \"Yellow\",\n");
question.append(" \"gender\": \"Male\"\n");
question.append(" },\n");
question.append(" {\n");
question.append(" \"sightArticleId\": 67890,\n");
question.append(" \"percentage\": 93.3,\n");
question.append(" \"findSpot\": \"서울특별시 서초구 서초동\",\n");
question.append(" \"sightedAt\": \"2024-12-22\",\n");
question.append(" \"breed\": \"Labrador\",\n");
question.append(" \"color\": \"Yellow\",\n");
question.append(" \"gender\": \"Male\"\n");
question.append(" }\n");
question.append("]\n\n");

question.append("목격 정보는 반드시 `percentage` 내림차순으로 정렬해주세요. " +
"그리고 50% 이상만 응답해 주세요.\n\n");

// 실종 동물 정보
LostResponse lostArticle = (LostResponse) articleResponse;
question.append("실종동물에 대한 설명 : lostArticle.getDescription()");
question.append("실종 위치: 경도:" + lostArticle.getLongitude() + ", 위도:" + lostArticle.getLatitude());
question.append("실종 동물 종: " + lostArticle.getBreed());
question.append("실종 동물 색상: " + lostArticle.getPetColor());
question.append("실종 동물 성별: " + lostArticle.getPetGender());
question.append("실종 동물 chipNumber: " + lostArticle.getChipNumber());
question.append("실종동물 실종된 위치: " + lostArticle.getLostLocation());
question.append("실종동물 실종일: " + lostArticle.getLostDate());

// 여러 목격 게시글 정보 추가
List<SightingListResponse> sightingList = boardService.getAllSightingBoardToList();

for (SightingListResponse sightingListResponse : sightingList) {
question.append("목격제보 ID: " + sightingListResponse.getBoardId());
question.append(", 목격동물 특징: " + sightingListResponse.getContent());
question.append(", 목격일: " + sightingListResponse.getSightedAt());
question.append(", 목격위치" + sightingListResponse.getAddress());
}

question.append("\n위 정보를 바탕으로 JSON 배열을 만들어 주세요. " +
"각 요소는 SightAIResponse 형식이며, 50% 미만은 제외하고 " +
"percentage 내림차순으로 정렬해 주세요.\n");

//AI에게 질문하기
try {
// 1. Gemini에게 질문 (질문 -> 응답)
String vertexAiGeminiResponse = vertexAiGeminiChatModel.call(question.toString());

// 응답에서 백틱 및 불필요한 텍스트 제거
String cleanedResponse = cleanJsonResponse(vertexAiGeminiResponse);

// json 형식인지 확인
if (!cleanedResponse.trim().startsWith("[")) {
log.error("Invalid response format: {}", cleanedResponse);
throw new IllegalArgumentException("AI 응답이 JSON 형식이 아닙니다.");
}

// 2. JSON 데이터를 DTO로 매핑 <List>버전
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); //jackson 날짜추가

JavaType listType = objectMapper.getTypeFactory()
.constructCollectionType(List.class, SightAIResponse.class);
List<SightAIResponse> answerList = objectMapper.readValue(cleanedResponse, listType);

return ResponseEntity.ok(answerList);
} catch (IllegalArgumentException e) {
log.error("AI answer failed {}", e.getMessage());
throw new RuntimeException("AI 응답이 유효하지 않습니다.");
} catch (Exception e) {
log.error("Error while processing AI response: {}", e.getMessage());
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 처리 중 문제가 발생했습니다.", e);
}
}

/**
* AI 응답에서 백틱 및 불필요한 텍스트 제거
*/
private String cleanJsonResponse(String response) {
// 백틱과 "```json" 제거
String cleaned = response.replaceAll("```json", "").replaceAll("```", "").trim();
log.debug("Cleaned response: {}", cleaned);
return cleaned;
}
}

34 changes: 34 additions & 0 deletions src/main/java/com/mallangs/domain/ai/dto/SightAIResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.mallangs.domain.ai.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

import java.time.LocalDate;

@Data
@JsonIgnoreProperties(ignoreUnknown = true) // DTO에 정의되지 않은 필드는 무시
public class SightAIResponse {

@JsonProperty("sightArticleId")
private Long sightArticleId;

@JsonProperty("percentage")
private Double percentage;

@JsonProperty("findSpot")
private String findSpot;

@JsonProperty("sightedAt")
private LocalDate sightedAt;

@JsonProperty("breed")
private String breed;

@JsonProperty("color")
private String color;

@JsonProperty("gender")
private String gender;

}
Loading

0 comments on commit d0bcba5

Please sign in to comment.