From fc954022f867520f0dcc8b4fd9c8ffdc0130fcc5 Mon Sep 17 00:00:00 2001 From: Jihun-Hwang Date: Tue, 17 Dec 2024 18:49:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(1?= =?UTF-8?q?=EC=B0=A8=20=EB=8D=B0=EB=AA=A8=20=EA=B5=AC=ED=98=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/generator/client/OpenAiClient.kt | 2 +- .../com/few/domain/generator/core/ChatGpt.kt | 59 +++++++++++++- .../domain/generator/core/GroupNewsModel.kt | 71 +++++++++++++++++ .../few/domain/generator/core/NewsGrouper.kt | 76 +++++++++++++++++++ .../usecase/ExecuteCrawlerUseCase.kt | 4 + 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 domain-generator/src/main/kotlin/com/few/domain/generator/core/GroupNewsModel.kt create mode 100644 domain-generator/src/main/kotlin/com/few/domain/generator/core/NewsGrouper.kt diff --git a/domain-generator/src/main/kotlin/com/few/domain/generator/client/OpenAiClient.kt b/domain-generator/src/main/kotlin/com/few/domain/generator/client/OpenAiClient.kt index c6d1ab480..be3dc91a5 100644 --- a/domain-generator/src/main/kotlin/com/few/domain/generator/client/OpenAiClient.kt +++ b/domain-generator/src/main/kotlin/com/few/domain/generator/client/OpenAiClient.kt @@ -14,5 +14,5 @@ import org.springframework.web.bind.annotation.RequestBody ) interface OpenAiClient { @PostMapping - fun summarizeNews(@RequestBody request: OpenAiRequest): OpenAiResponse + fun send(@RequestBody request: OpenAiRequest): OpenAiResponse } \ No newline at end of file diff --git a/domain-generator/src/main/kotlin/com/few/domain/generator/core/ChatGpt.kt b/domain-generator/src/main/kotlin/com/few/domain/generator/core/ChatGpt.kt index 9f91e049f..d53e11125 100644 --- a/domain-generator/src/main/kotlin/com/few/domain/generator/core/ChatGpt.kt +++ b/domain-generator/src/main/kotlin/com/few/domain/generator/core/ChatGpt.kt @@ -3,6 +3,7 @@ package com.few.domain.generator.core import com.few.domain.generator.client.OpenAiClient import com.few.domain.generator.client.request.OpenAiRequest import com.google.gson.Gson +import com.google.gson.JsonObject import com.google.gson.reflect.TypeToken import org.springframework.stereotype.Component @@ -11,7 +12,7 @@ class ChatGpt( private val openAiClient: OpenAiClient, private val fewGson: Gson, ) { - fun makeSummaryPrompt(news: NewsModel): List> { + private fun makeSummaryPrompt(news: NewsModel): List> { val command = """ 다음 뉴스 기사를 분석하고 요약해주세요: @@ -55,10 +56,64 @@ class ChatGpt( messages = prompt ) - val response = openAiClient.summarizeNews(request) + val response = openAiClient.send(request) val resultContent = response.choices.firstOrNull()?.message?.content ?: throw Exception("요약 결과를 찾을 수 없습니다.") return fewGson.fromJson(resultContent, object : TypeToken>() {}.type) } + + fun groupNewsWithChatGPT(newsList: List): JsonObject { + val promptMessages = makeGroupingPrompt(newsList) + + val request = OpenAiRequest( + model = "gpt-4", + messages = promptMessages + ) + + val response = openAiClient.send(request) + val resultContent = response.choices[0].message.content.trim() + + // JSON 형태로 변환 + return fewGson.fromJson(resultContent, JsonObject::class.java) // TODO 리턴타입 변경 + } + + private fun makeGroupingPrompt(newsList: List): List> { + val newsSummaries = newsList.joinToString("\n") { "${it.id}: ${it.summary}" } + val command = """ + 다음은 여러 뉴스 기사의 요약입니다. 이 뉴스들을 비슷한 주제끼리 그룹핑해주세요: + + $newsSummaries + + # 지침: + 1. 뉴스 요약들을 분석하고 비슷한 주제끼리 그룹화하세요. + 2. 각 그룹에 적절한 주제를 부여하세요. + 3. topic은 구체적인 문장으로 작성해주세요. + 4. 응답은 반드시 다음 JSON 형식을 따라야 합니다: + + { + "groups": [ + { + "topic": "그룹의 주제", + "news_ids": ["id1", "id3", "id5" ...] + }, + { + "topic": "다른 그룹의 주제", + "news_ids": ["id2", "id4", "id6" ...] + } + ] + } + + # 주의사항: + - 응답은 오직 위의 JSON 형식만 포함해야 합니다. 다른 설명이나 내용을 추가하지 마세요. + - 각 그룹의 "news_ids"는 위 목록의 뉴스 ID를 나타냅니다. + - 그룹의 수에는 제한이 없지만, 너무 많은 그룹을 만들지 않도록 주의하세요. 뉴스 총 개수의 10% 이상의 그룹을 만들지 않도록 주의하세요. + - 그룹의 주제는 추상적인 문장을 사용하지 말고 구체적인 문장으로 작성해주세요. + """.trimIndent() + + return listOf( // TODO 클래스 정의 + mapOf("role" to "system", "content" to "당신은 뉴스 기사를 주제별로 그룹핑하는 전문가입니다. 주어진 뉴스 요약들을 분석하고 비슷한 주제끼리 그룹화해야 합니다."), + mapOf("role" to "user", "content" to command) + ) + } } \ No newline at end of file diff --git a/domain-generator/src/main/kotlin/com/few/domain/generator/core/GroupNewsModel.kt b/domain-generator/src/main/kotlin/com/few/domain/generator/core/GroupNewsModel.kt new file mode 100644 index 000000000..988493615 --- /dev/null +++ b/domain-generator/src/main/kotlin/com/few/domain/generator/core/GroupNewsModel.kt @@ -0,0 +1,71 @@ +package com.few.domain.generator.core + +data class GroupNewsModel( + val topic: String = "", + val news: List = listOf(), + val section: SectionContentModel = SectionContentModel(), +) { + fun toMap(): Map { + return mapOf( + "topic" to topic, + "news" to news.map { it.toMap() }, + "section" to section.toDict() + ) + } + + companion object { + fun fromMap(data: Map): GroupNewsModel { + val newsList = (data["news"] as List>).map { NewsModel.fromMap(it) } + val sectionData = SectionContentModel.fromDict(data["section"] as Map? ?: emptyMap()) + return GroupNewsModel( + topic = data["topic"] as String, + news = newsList, + section = sectionData + ) + } + } +} + +data class SectionContentModel( + val title: String = "", + val contents: List = listOf(), +) { + fun toDict(): Map { + return mapOf( + "title" to title, + "contents" to contents.map { it.toDict() } + ) + } + + companion object { + fun fromDict(data: Map): SectionContentModel { + val contentsList = + (data["contents"] as? List>)?.map { ContentModel.fromDict(it) } ?: emptyList() + return SectionContentModel( + title = data["title"] as? String ?: "", + contents = contentsList + ) + } + } +} + +data class ContentModel( + val subTitle: String = "", + val body: String = "", +) { + fun toDict(): Map { + return mapOf( + "subTitle" to subTitle, + "body" to body + ) + } + + companion object { + fun fromDict(data: Map): ContentModel { + return ContentModel( + subTitle = data["subTitle"] as? String ?: "", + body = data["body"] as? String ?: "" + ) + } + } +} \ No newline at end of file diff --git a/domain-generator/src/main/kotlin/com/few/domain/generator/core/NewsGrouper.kt b/domain-generator/src/main/kotlin/com/few/domain/generator/core/NewsGrouper.kt new file mode 100644 index 000000000..f6a8d5356 --- /dev/null +++ b/domain-generator/src/main/kotlin/com/few/domain/generator/core/NewsGrouper.kt @@ -0,0 +1,76 @@ +package com.few.domain.generator.core + +import java.io.File +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.reflect.TypeToken +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component + +@Component +class NewsGrouper( + private val chatGpt: ChatGpt, + private val fewGson: Gson, +) { + private val log = KotlinLogging.logger {} + + fun groupAndSaveNews(inputFilePath: String, outputFilePath: String) { + val newsList = loadSummarizedNews(inputFilePath) + + log.info { "뉴스 그룹화 진행 중..." } + + val groupedNews = chatGpt.groupNewsWithChatGPT(newsList) + + log.info { "그룹화된 뉴스 저장 중..." } + saveGroupedNewsToJson(groupedNews, newsList, outputFilePath) + + log.info { "뉴스 그룹화 완료." } + log.info { "${groupedNews.size()}개의 그룹으로 뉴스가 분류되어 '$outputFilePath' 파일로 저장되었습니다." } + } + + private fun loadSummarizedNews(inputFilePath: String): List { + val fileContent = File(inputFilePath).readText(Charsets.UTF_8) + + // JSON 문자열을 List> 형태로 변환 + val typeToken = object : TypeToken>>() {}.type + val data: List> = fewGson.fromJson(fileContent, typeToken) + + // 각 항목을 NewsModel 객체로 변환 + return data.map { NewsModel.fromMap(it) } + } + + private fun saveGroupedNewsToJson( + groupedNews: JsonObject, + newsList: List, + outputFilePath: String, + ) { + val result = mutableListOf() + + // "groups" 필드를 JsonArray로 추출 + val groupElements = groupedNews.getAsJsonArray("groups") + + for (groupElement in groupElements) { + val group = groupElement.asJsonObject + + // "news_ids"를 JsonArray로 추출하고 String 리스트로 변환 + val groupNewsIds = group.getAsJsonArray("news_ids").map { it.asString } + + // 뉴스 ID가 그룹에 포함된 뉴스 필터링 + val newsInGroup = newsList.filter { it.id in groupNewsIds } + + // 뉴스가 3개 이상인 경우만 추가 + if (newsInGroup.size >= 3) { + val groupNews = GroupNewsModel( + topic = group.getAsJsonPrimitive("topic").asString, + news = newsInGroup + ) + result.add(groupNews) + println(groupNewsIds) + } + } + + // JSON 직렬화 및 파일 저장 + val groupNewsData = result.map { it.toMap() } + File(outputFilePath).writeText(fewGson.toJson(groupNewsData), Charsets.UTF_8) + } +} \ No newline at end of file diff --git a/domain-generator/src/main/kotlin/com/few/domain/generator/usecase/ExecuteCrawlerUseCase.kt b/domain-generator/src/main/kotlin/com/few/domain/generator/usecase/ExecuteCrawlerUseCase.kt index 196f6f5f0..364ba7b2a 100644 --- a/domain-generator/src/main/kotlin/com/few/domain/generator/usecase/ExecuteCrawlerUseCase.kt +++ b/domain-generator/src/main/kotlin/com/few/domain/generator/usecase/ExecuteCrawlerUseCase.kt @@ -25,6 +25,7 @@ class ExecuteCrawlerUseCase( * 없는 경우 크롤링 시작 */ + // 1. 네이버 뉴스 크롤링 log.info { "크롤링이 시작" } val newsUrls = naverNewsCrawler.getNaverNewsUrls(useCaseIn.sid) @@ -41,8 +42,11 @@ class ExecuteCrawlerUseCase( naverNewsCrawler.saveContentAsJson(results) log.info { "크롤링이 완료 및 요약 시작" } + // 2. 뉴스 추출 및 요약 extractor.extractAndSaveNews("crawled_news.json", "extracted_news.json") + // 3. 뉴스 그룹화 + ExecuteCrawlerUseCaseOut( useCaseIn.sid, listOf(UUID.randomUUID().toString()) // TODO: DB 저장 시 크롤링 고유 ID 응답