-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] AI 기반 독후감 생성 api 구현 #323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06de498
dd6b710
4285dfc
12549a0
15b8b7c
86bda32
c2d2d2f
baa4edb
bf88a45
97c11d0
9886a21
139f9bd
f1809fe
e5a0384
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package konkuk.thip.common.ai.adapter.out; | ||
|
|
||
| import konkuk.thip.book.domain.Book; | ||
| import konkuk.thip.common.ai.application.out.GeminiLoadPort; | ||
| import konkuk.thip.common.exception.InternalServerException; | ||
| import konkuk.thip.common.exception.code.ErrorCode; | ||
| import konkuk.thip.roompost.domain.Record; | ||
| import konkuk.thip.user.domain.User; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.ai.chat.client.ChatClient; | ||
| import org.springframework.ai.chat.prompt.PromptTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class GeminiAdapter implements GeminiLoadPort { | ||
|
|
||
| private final ChatClient chatClient; | ||
|
|
||
| private static final String TEMPLATE = """ | ||
| 시스템: 당신은 한국어 글쓰기 튜터이자 서평가이다. 사용자의 기록을 바탕으로 논리적이고 깔끔한 독서 감상문을 작성한다. | ||
|
|
||
| 어투/스타일 규칙: | ||
| - 시점: 1인칭(저는/제가) | ||
| - 종결어미: 일관된 존댓말(~습니다/~합니다) | ||
| - 문장은 간결하고 매 단락의 주제가 분명해야 함 | ||
| - 과도한 추측/단정 금지, 기록에 근거하여 주장 전개 | ||
| - 필요 시 (p.페이지)로 근거 표기 | ||
|
|
||
| 책 메타데이터: | ||
| - 책 제목: {bookTitle} | ||
| - 책 설명: {bookDescription} | ||
|
|
||
| 사용자 정보: | ||
| - 닉네임: {nickname} | ||
| - 칭호: {alias} | ||
| - 현재 누적 독후감 생성 횟수: {reviewCount}회 | ||
|
|
||
| 입력 기록(페이지 오름차순, 요약/정제됨): | ||
| {records} | ||
|
|
||
| 작성 지시: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 옼ㅋㅋㅋ 좋네요 이거 현준띠가 직접 짜신건가여
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ai 도움을 좀 받았습니다 ㅎㅎ |
||
| 1) 서론-본론-결론 3단 구성으로 작성합니다. | ||
| 2) 서론: 책 설명을 바탕으로 이번 독서의 관점/목표를 간단히 밝힙니다. | ||
| 3) 본론: 기록에서 드러난 핵심 주제 2~3가지를 선택해 근거와 함께 설명합니다. 필요한 곳에 (p.xx) 표기. | ||
| 4) 결론: 독서 전후의 생각 변화/적용 계획을 간단히 정리합니다. | ||
| 5) 전체 분량은 대략 {minLen}~{maxLen}자 수준으로 맞춥니다. | ||
|
|
||
| 이제 위 정보를 바탕으로 독서 감상문을 작성하세요. | ||
| """; | ||
|
|
||
| @Override | ||
| public String generateRecordReview(User user, List<Record> records, Book book, int minLength, int maxLength) { | ||
| // 방어 로직: records 비어있을 수 있음 (상위 유효성에서 걸러도 한 번 더 안전장치) | ||
| if (records == null || records.isEmpty()) { | ||
| throw new InternalServerException(ErrorCode.GEMINI_API_REQUEST_ERROR, | ||
| new IllegalArgumentException("기록이 비어있습니다.")); | ||
| } | ||
|
|
||
| String recordsBlock = records.stream() | ||
| .map(r -> "- p." + r.getPage() + ": " + r.getContent()) | ||
| .collect(Collectors.joining("\n")); | ||
|
|
||
| String bookTitle = book.getTitle(); | ||
| String bookDesc = book.getDescription() == null ? "설명 없음" : book.getDescription(); | ||
|
|
||
| // 템플릿 렌더링 | ||
| String prompt = new PromptTemplate(TEMPLATE).render(Map.of( | ||
| "bookTitle", bookTitle, | ||
| "bookDescription", bookDesc, | ||
| "nickname", user.getNickname(), | ||
| "alias", user.getAlias().getValue(), | ||
| "reviewCount", String.valueOf(user.getRecordReviewCount()), | ||
| "records", recordsBlock, | ||
| "minLen", minLength, // 필요 시 파라미터화 | ||
| "maxLen", maxLength // 필요 시 파라미터화 | ||
| )); | ||
|
|
||
| try { | ||
| // Spring AI ChatClient 호출 | ||
| return chatClient | ||
| .prompt() | ||
| .user(prompt) | ||
| .call() | ||
| .content(); | ||
| } catch (Exception e) { | ||
| throw new InternalServerException(ErrorCode.GEMINI_API_RESPONSE_ERROR, e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package konkuk.thip.common.ai.application.out; | ||
|
|
||
| import konkuk.thip.book.domain.Book; | ||
| import konkuk.thip.roompost.domain.Record; | ||
| import konkuk.thip.user.domain.User; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface GeminiLoadPort { | ||
| String generateRecordReview(User user, List<Record> records, Book book, int minLength, int maxLength); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package konkuk.thip.config; | ||
|
|
||
| import org.springframework.ai.chat.client.ChatClient; | ||
| import org.springframework.ai.chat.model.ChatModel; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| @Configuration | ||
| public class GeminiAiConfig { | ||
| @Bean | ||
| public ChatClient chatClient(ChatModel geminiModel) { | ||
| return ChatClient.builder(geminiModel).build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package konkuk.thip.roompost.adapter.in.web.request; | ||
|
|
||
| import konkuk.thip.roompost.application.port.in.dto.record.RecordAiUsageResult; | ||
|
|
||
| public record RecordAiUsageResponse( | ||
| Integer recordReviewCount, | ||
| Integer recordCount | ||
| ) { | ||
| public static RecordAiUsageResponse of(RecordAiUsageResult result) { | ||
| return new RecordAiUsageResponse( | ||
| result.recordReviewCount(), | ||
| result.recordCount() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package konkuk.thip.roompost.adapter.in.web.response; | ||
|
|
||
| import konkuk.thip.roompost.application.port.in.dto.record.RecordReviewCreateResult; | ||
|
|
||
| public record RecordReviewCreateResponse( | ||
| String content, | ||
| int count | ||
| ) { | ||
| public static RecordReviewCreateResponse of(RecordReviewCreateResult result) { | ||
| return new RecordReviewCreateResponse(result.content(), result.reviewCount()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,4 +22,17 @@ public interface RecordJpaRepository extends JpaRepository<RecordJpaEntity, Long | |
| @Modifying(clearAutomatically = true, flushAutomatically = true) | ||
| @Query("UPDATE RecordJpaEntity r SET r.status = 'INACTIVE' WHERE r.userJpaEntity.userId = :userId") | ||
| void softDeleteAllByUserId(@Param("userId") Long userId); | ||
|
|
||
| @Query("SELECT r FROM RecordJpaEntity r " + | ||
| "WHERE r.roomJpaEntity.roomId = :roomId " + | ||
| "AND r.userJpaEntity.userId = :userId " + | ||
| "AND r.isOverview = false " + | ||
| "ORDER BY r.page ASC") | ||
| List<RecordJpaEntity> findAllByRoomIdAndUserIdOrderByPageAsc(Long roomId, Long userId); | ||
|
|
||
| @Query("SELECT COUNT(r) FROM RecordJpaEntity r " + | ||
| "WHERE r.roomJpaEntity.roomId = :roomId " + | ||
| "AND r.userJpaEntity.userId = :userId " + | ||
| "AND r.isOverview = false") | ||
| Integer countAllByRoomIdAndUserId(Long roomId, Long userId); | ||
|
Comment on lines
+26
to
+37
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확인했습니다 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package konkuk.thip.roompost.application.port.in; | ||
|
|
||
| import konkuk.thip.roompost.application.port.in.dto.record.RecordAiUsageResult; | ||
|
|
||
| public interface RecordAiUsageUseCase { | ||
| RecordAiUsageResult getUserAiUsage(Long userId, Long roomId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package konkuk.thip.roompost.application.port.in; | ||
|
|
||
| import konkuk.thip.roompost.application.port.in.dto.record.RecordReviewCreateResult; | ||
|
|
||
| public interface RecordReviewCreateUseCase { | ||
|
|
||
| RecordReviewCreateResult createAiRecordReview(Long roomId, Long userId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package konkuk.thip.roompost.application.port.in.dto.record; | ||
|
|
||
| public record RecordAiUsageResult( | ||
| Integer recordReviewCount, | ||
| Integer recordCount | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package konkuk.thip.roompost.application.port.in.dto.record; | ||
|
|
||
| public record RecordReviewCreateResult( | ||
| String content, | ||
| int reviewCount | ||
| ) { | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
잘못된 Spring AI 스타터 선택으로 컴파일/런타임 실패 가능
구글 Gemini 연동인데
spring-ai-openai-spring-boot-starter를 추가하면 OpenAI 전용 오토컨피그만 제공되어,GeminiAiConfig에서 필요한org.springframework.ai.googleai.gemini관련 빈을 못 찾아 컴파일이 깨지거나 런타임에 빈 생성이 실패합니다. Google Gemini용 스타터로 교체해야 합니다.📝 Committable suggestion
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
google ai로 하면 빈 등록이 안되어서 openai 의존성을 주입하고 내부 모델로 google ai의 gemini 모델을 사용한거야.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
💡 Result:
Short answer — in Spring AI 1.0.0-M6 you register Gemini (Vertex AI) clients and functions the same way you register other Spring AI beans: rely on the VertexAiGemini auto-configuration and expose beans for the ChatClient and any function callbacks.
Key steps (minimal example)
public class ChatClientConfig {
@bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
public Function<MyRequest,MyResponse> currentWeather() { ... }
public FunctionCallback weatherFunctionInfo() {
return FunctionCallback.builder()
.function("CurrentWeather", new MyWeatherService())
.description("Get the current weather")
.schemaType(SchemaType.OPEN_API_SCHEMA) // IMPORTANT for Gemini function calling
.inputType(MyWeatherService.Request.class)
.build();
}
References
If you want, I can paste a ready-to-run small code example (pom/deps + config + sample function) tailored to M6.
🌐 Web query:
💡 Result:
Maven coordinates (Spring AI 1.0.0‑M6):
Maven dependency:
org.springframework.ai
spring-ai-vertex-ai-gemini-spring-boot-starter
1.0.0-M6
Sources: Maven Central index and mvnrepository listing for the 1.0.0‑M6 starter. [1][2]
References
[1] Maven Central index for org.springframework.ai/spring-ai-vertex-ai-gemini-spring-boot-starter (1.0.0‑M6). (repo1.maven.org)
[2] mvnrepository entry for spring-ai-vertex-ai-gemini-spring-boot-starter 1.0.0‑M6. (mvnrepository.com)
Citations:
🌐 Web query:
💡 Result:
Short answer: Spring AI doesn't provide a standalone "Google AI Gemini" starter separate from Vertex AI in 1.0.0‑M6. The provided starters target Gemini via Vertex AI — use the Vertex/Gemini starter coordinates.
Maven (example):
Alternate/newer autoconfiguration name shown in docs:
Sources:
[1] mvnrepository listing for spring-ai-vertex-ai-gemini-spring-boot-starter 1.0.0‑M6.
[2] Spring AI 1.0.0‑M6 release notes.
[3] Spring AI reference (VertexAI Gemini Chat / starter naming).
🌐 Web query:
💡 Result:
You can use the Spring AI OpenAI starter but point its OpenAI base-url at Vertex AI’s OpenAI-compatible endpoint and set the Gemini model name. Also supply a Google Cloud access token (refreshable) as the OpenAI api-key. Example application.yml:
spring:
ai:
openai:
# use Vertex AI OpenAI-compatible endpoint (replace project_id + location)
base-url: https://aiplatform.googleapis.com/v1/projects/YOUR_PROJECT_ID/locations/YOUR_LOCATION/endpoints/openapi
# pass a Google Cloud access token here (refresh before it expires)
api-key: YOUR_GOOGLE_OAUTH_ACCESS_TOKEN
chat:
completions-path: /v1/chat/completions
options:
model: google/gemini-2.0-flash-001
temperature: 0.7
Notes:
References:
[1] Google Cloud — OpenAI compatibility / Vertex AI (examples showing base_url and use of Google credentials).
[2] Spring AI — OpenAI Chat configuration properties (spring.ai.openai.base-url, chat options).
🏁 Script executed:
Length of output: 1773
🏁 Script executed:
Length of output: 315
🏁 Script executed:
Length of output: 579
🌐 Web query:
💡 Result:
Short answer — in Spring AI 1.0.0‑M6 the OpenAI starter auto‑configures a ChatModel bean named by the factory method openAiChatModel. The registration details:
If you want to override it, define your own ChatModel/OpenAiChatModel bean or set spring.ai.openai.chat.enabled=false. References: Spring AI 1.0.0‑M6 API docs for OpenAiAutoConfiguration and OpenAiChatModel. [1][2]
Sources:
[1] Spring AI 1.0.0‑M6 — OpenAiAutoConfiguration (api docs).
[2] Spring AI 1.0.0‑M6 — OpenAiChatModel (api docs).
@buzz0331, 설명 감사합니다! 이해했습니다.
OpenAI 스타터를 사용하면서 내부적으로 Google Gemini 모델을 사용하시는 방식이군요. 다만, 현재 코드베이스에서는 Spring AI 관련 설정 파일(
application.yml/application.properties)이 보이지 않는데, 외부에서 환경 변수나 설정을 통해 다음과 같이 구성하고 계신 건가요?spring.ai.openai.base-url: Google의 OpenAI 호환 엔드포인트로 설정spring.ai.openai.api-key: Google Cloud 액세스 토큰spring.ai.openai.chat.options.model:google/gemini-2.0-flash-001등 Gemini 모델명이 설정들이 환경 변수나 Kubernetes Secrets 등으로 주입되고 있다면 현재 방식이 동작할 것입니다.
만약 이러한 외부 설정이 아직 구성되지 않은 상태라면,
OpenAiChatModel이 기본적으로 OpenAI의 공식 API(api.openai.com)로 요청을 보내게 되어 Gemini 연동이 실제로는 작동하지 않을 수 있습니다. 현재 설정 상태를 확인해 주실 수 있을까요?✏️ Learnings added
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ㅇㅇ 너말대로 yml 파일을 secret으로 관리하고 있어.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.