Skip to content

Commit

Permalink
Feat: 사용자는 허브 태그 자동 생성 기능을 이용할 수 있다.
Browse files Browse the repository at this point in the history
Feat: 사용자는 허브 태그 자동 생성 기능을 이용할 수 있다.
  • Loading branch information
hseong3243 authored Apr 5, 2024
2 parents 683692b + 59cfba0 commit 4924fad
Show file tree
Hide file tree
Showing 17 changed files with 480 additions and 1 deletion.
13 changes: 13 additions & 0 deletions src/main/java/com/seong/shoutlink/domain/common/ApiClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.seong.shoutlink.domain.common;

import java.util.List;
import java.util.Map;

public interface ApiClient {

Map<String, Object> post(
String url,
Map<String, List<String>> uriVariables,
Map<String, List<String>> headers,
String requestBody);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.seong.shoutlink.domain.link;

import com.seong.shoutlink.domain.link.Link;
import com.seong.shoutlink.domain.linkbundle.LinkBundle;
import java.util.List;
import lombok.Getter;

@Getter
public class LinkBundleAndLinks {

private final LinkBundle linkBundle;
private final List<Link> links;

public LinkBundleAndLinks(LinkBundle linkBundle, List<Link> links) {
this.linkBundle = linkBundle;
this.links = links;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.seong.shoutlink.domain.tag.service;

import com.seong.shoutlink.domain.tag.service.ai.GenerateAutoTagCommand;
import com.seong.shoutlink.domain.tag.service.ai.GeneratedTag;
import java.util.List;

public interface AutoGenerativeClient {

List<GeneratedTag> generateTags(GenerateAutoTagCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.seong.shoutlink.domain.tag.service.ai;

import com.seong.shoutlink.domain.link.Link;
import com.seong.shoutlink.domain.link.LinkBundleAndLinks;
import com.seong.shoutlink.domain.linkbundle.LinkBundle;
import java.util.List;

public record GenerateAutoTagCommand(List<AutoTagLinkBundle> linkBundles) {

public record AutoTagLinkBundle(String description, List<AutoTagLink> links) {

public static AutoTagLinkBundle from(LinkBundle linkBundle, List<AutoTagLink> autoTagLinks) {
return new AutoTagLinkBundle(linkBundle.getDescription(), autoTagLinks);
}
}

public record AutoTagLink(String url, String description) {

public static AutoTagLink from(Link link) {
return new AutoTagLink(link.getUrl(), link.getDescription());
}
}

public static GenerateAutoTagCommand create(List<LinkBundleAndLinks> linkBundlesAndLinks) {
List<AutoTagLinkBundle> content = linkBundlesAndLinks.stream()
.map(linkBundleAndLinks -> {
List<AutoTagLink> autoTagLinks = linkBundleAndLinks.getLinks().stream()
.map(AutoTagLink::from)
.toList();
return AutoTagLinkBundle.from(linkBundleAndLinks.getLinkBundle(), autoTagLinks);
})
.toList();
return new GenerateAutoTagCommand(content);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.seong.shoutlink.domain.tag.service.ai;

public record GeneratedTag(String name) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.seong.shoutlink.global.client.ai;

import com.seong.shoutlink.domain.tag.service.ai.GenerateAutoTagCommand;
import com.seong.shoutlink.domain.tag.service.ai.GenerateAutoTagCommand.AutoTagLink;
import com.seong.shoutlink.domain.tag.service.ai.GenerateAutoTagCommand.AutoTagLinkBundle;

public record AutoTagPrompt(String request, GenerateAutoTagCommand command) {

public String toPromptString() {
StringBuilder sb = new StringBuilder();
sb.append("요청:").append(request).append("\n")
.append("요약 대상:");
sb.append("{");
for (AutoTagLinkBundle linkBundle : command.linkBundles()) {
sb.append("링크 묶음 설명:").append(linkBundle.description());
sb.append("[");
for (AutoTagLink link : linkBundle.links()) {
sb.append("{");
sb.append("링크 설명:").append(link.description()).append(",")
.append("url:").append(link.url());
sb.append("}");
}
sb.append("]");
}
sb.append("}");
return sb.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.seong.shoutlink.global.client.ai;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seong.shoutlink.domain.common.ApiClient;
import com.seong.shoutlink.domain.exception.ErrorCode;
import com.seong.shoutlink.domain.exception.ShoutLinkException;
import com.seong.shoutlink.domain.tag.service.AutoGenerativeClient;
import com.seong.shoutlink.domain.tag.service.ai.GenerateAutoTagCommand;
import com.seong.shoutlink.domain.tag.service.ai.GeneratedTag;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class GeminiClient implements AutoGenerativeClient {

private static final Map<String, List<String>> HEADERS = new HashMap<>();
private static final Map<String, List<String>> URI_VARIABLES = Map.of();
private static final String API_KEY_HEADER = "x-goog-api-key";
private static final String GENERATE_TAG_PROMPT =
"""
주어진 링크 묶음 설명, 링크 설명, url을 통해 사용자의 관심사를 추론하세요.
추론한 관심사는 {0}개의 키워드로 요약해서 응답하세요.
각 키워드는 10자 이내여야 하며 둘 이상인 경우 ,로 구분하세요.
""";

static {
HEADERS.put("Content-Type", List.of("application/json"));
}

private final String url;
private final ObjectMapper objectMapper;
private final ApiClient apiClient;

public GeminiClient(String url, String apiKey, ObjectMapper objectMapper, ApiClient apiClient) {
this.url = url;
this.objectMapper = objectMapper;
this.apiClient = apiClient;
HEADERS.put(API_KEY_HEADER, List.of(apiKey));
}

@Override
public List<GeneratedTag> generateTags(GenerateAutoTagCommand command) {
String requestPrompt = MessageFormat.format(GENERATE_TAG_PROMPT, 1);
AutoTagPrompt autoTagPrompt = new AutoTagPrompt(requestPrompt, command);
GeminiRequest geminiRequest = GeminiRequest.create(autoTagPrompt.toPromptString());
String requestBody = "";
try {
requestBody = objectMapper.writeValueAsString(geminiRequest);
} catch (JsonProcessingException e) {
throw new ShoutLinkException("문자열 변환에 실패하였습니다.", ErrorCode.ILLEGAL_ARGUMENT);
}

log.info("[Gemini] 태그 자동 생성 요청");
Object rawCandidates = apiClient.post(url, URI_VARIABLES, HEADERS, requestBody)
.get("candidates");
List<GeminiResponse.Candidate> candidates
= objectMapper.convertValue(rawCandidates, new TypeReference<>() {
});

log.info("[Gemini] 자동 생성된 태그 변환 시작");
String[] splitTags = candidates.stream()
.findFirst()
.map(GeminiResponse.Candidate::getResponse)
.map(tags -> tags.trim().split(","))
.orElseThrow(() -> new ShoutLinkException("유효한 candidate가 포함되어 있지 않습니다.",
ErrorCode.ILLEGAL_ARGUMENT));

log.info("[Gemini] 자동 생성된 태그 변환 성공");
return Arrays.stream(splitTags)
.map(rawTag -> new GeneratedTag(rawTag.trim()))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.seong.shoutlink.global.client.ai;

import java.util.List;

public record GeminiRequest(List<Content> contents) {

record Content(List<Part> parts) {

}

record Part(String text) {

}

static GeminiRequest create(String text) {
Part part = new Part(text);
Content content = new Content(List.of(part));
return new GeminiRequest(List.of(content));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.seong.shoutlink.global.client.ai;

import com.seong.shoutlink.domain.exception.ErrorCode;
import com.seong.shoutlink.domain.exception.ShoutLinkException;
import java.util.List;

public record GeminiResponse(List<Candidate> candidates) {

record Candidate(Content content) {

public String getResponse() {
return content.getResponse();
}
}

record Content(List<Part> parts, String role) {

public String getResponse() {
Part part = parts.stream()
.findFirst()
.orElseThrow(() -> new ShoutLinkException("", ErrorCode.ILLEGAL_ARGUMENT));
return part.text();
}
}

record Part(String text) {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.seong.shoutlink.global.client.api;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seong.shoutlink.domain.common.ApiClient;
import com.seong.shoutlink.domain.exception.ErrorCode;
import com.seong.shoutlink.domain.exception.ShoutLinkException;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;

public class RestApiClient implements ApiClient {

private final RestClient restClient;
private final ObjectMapper objectMapper;

public RestApiClient(ObjectMapper objectMapper) {
restClient = RestClient.create();
this.objectMapper = objectMapper;
}

@Override
public Map<String, Object> post(
String url,
Map<String, List<String>> uriVariables,
Map<String, List<String>> headers,
String requestBody) {
ResponseEntity<String> entity = restClient.post()
.uri(url, uriVariables)
.headers(httpHeaders -> httpHeaders.putAll(headers))
.body(requestBody)
.retrieve()
.toEntity(String.class);

try {
return objectMapper.readValue(entity.getBody(), new TypeReference<>() {});
} catch (JsonProcessingException e) {
throw new ShoutLinkException("API 응답을 읽는데 실패하였습니다.", ErrorCode.ILLEGAL_ARGUMENT);
}
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/seong/shoutlink/global/config/AiConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.seong.shoutlink.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.seong.shoutlink.domain.tag.service.AutoGenerativeClient;
import com.seong.shoutlink.domain.common.ApiClient;
import com.seong.shoutlink.global.client.ai.GeminiClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

@Bean
public AutoGenerativeClient autoGenerativeClient(
@Value("${gemini.url}") String url,
@Value("${gemini.api-key}") String apiKey,
ObjectMapper objectMapper,
ApiClient apiClient) {
return new GeminiClient(url, apiKey, objectMapper, apiClient);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/seong/shoutlink/global/config/ApiConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.seong.shoutlink.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.seong.shoutlink.domain.common.ApiClient;
import com.seong.shoutlink.global.client.api.RestApiClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ApiConfig {

@Bean
public ApiClient apiClient(ObjectMapper objectMapper) {
return new RestApiClient(objectMapper);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/shout-link-config
65 changes: 65 additions & 0 deletions src/test/java/com/seong/shoutlink/fixture/ApiFixture.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.seong.shoutlink.fixture;

public final class ApiFixture {

public static int DEFAULT_FIXED_TAG_COUNT = 3;

public static String geminiResponse() {
return """
{
"candidates": [
{
"content": {
"parts": [
{
"text": "태그1,태그2,태그3"
}
],
"role": "model"
},
"finishReason": "STOP",
"index": 0,
"safetyRatings": [
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_HARASSMENT",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"probability": "NEGLIGIBLE"
}
]
}
],
"promptFeedback": {
"safetyRatings": [
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_HARASSMENT",
"probability": "NEGLIGIBLE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"probability": "NEGLIGIBLE"
}
]
}
}
""";
}
}
Loading

0 comments on commit 4924fad

Please sign in to comment.