Skip to content
Merged
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
2 changes: 1 addition & 1 deletion admin-web/src/pages/dashboard/usage-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ interface UsageStats {
// 감정별 고정 색상(카테고리형이라 구분되는 팔레트를 쓴다).
const EMOTION_COLOR: Record<string, string> = {
JOY: '#f59e0b',
SADNESS: '#3b82f6',
ANGER: '#ef4444',
ANXIETY: '#8b5cf6',
GRUMPY: '#f97316',
WARM: '#ec4899',
QUIRKY: '#14b8a6',
}
const FALLBACK_COLOR = '#a1a1aa'
Expand Down
5 changes: 3 additions & 2 deletions admin-web/src/pages/prompt-playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ import { Textarea } from '@/components/ui/textarea'
import { PageHeader } from '@/components/page-header'
import { cn } from '@/lib/utils'

type Emotion = 'JOY' | 'WARM' | 'ANGER' | 'ANXIETY' | 'GRUMPY' | 'QUIRKY'
// 서버의 EmotionType과 같은 목록.
type Emotion = 'JOY' | 'SADNESS' | 'ANGER' | 'ANXIETY' | 'GRUMPY' | 'QUIRKY'

const CHARACTERS: { value: Emotion; label: string; emoji: string }[] = [
{ value: 'JOY', label: '기쁨', emoji: '😊' },
{ value: 'WARM', label: '다정', emoji: '🥰' },
{ value: 'SADNESS', label: '슬픔', emoji: '😢' },
{ value: 'ANGER', label: '분노', emoji: '😡' },
{ value: 'ANXIETY', label: '불안', emoji: '😰' },
{ value: 'GRUMPY', label: '까칠', emoji: '😤' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ data class PromptPreviewRequest(
@Size(max = 2_000, message = "과거 요약 한 줄은 2000자 이하여야 합니다.")
String,
>? = null,
@field:Schema(description = "등장 캐릭터(1명 이상 필수)")
@field:Schema(
description = "등장 캐릭터(1명 이상 필수)",
example = "[\"JOY\", \"SADNESS\"]",
)
@field:NotEmpty(message = "characters는 1개 이상이어야 합니다.")
val characters: List<EmotionType>,
// 프로덕션 상한(CharacterSelector.TOTAL_MAX=3)보다 여유를 둔 실험 상한. 오타(예: 1000)가
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ data class MessageResponse(
@field:Schema(
description = "감정 캐릭터 (캐릭터 메시지에만 존재)",
example = "JOY",
allowableValues = ["JOY", "ANGER", "ANXIETY", "GRUMPY", "WARM", "QUIRKY"],
allowableValues = ["JOY", "SADNESS", "ANGER", "ANXIETY", "GRUMPY", "QUIRKY"],
nullable = true,
)
val emotionType: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package com.nexters.gamss.emotion.domain

/**
* 감정 캐릭터 6종. 캐릭터 메시지가 어떤 감정의 말인지 나타낸다.
* 감정 캐릭터. 캐릭터 메시지가 어떤 감정의 말인지 나타낸다.
*
* 카드·메시지가 이 이름을 문자열로 저장하므로(`@Enumerated(EnumType.STRING)`) 상수를 지우거나 이름을
* 바꾸면 그 값으로 저장된 과거 레코드를 읽는 순간 역직렬화가 깨진다. 캐릭터 교체는 상수 이름이 아니라
* 마이그레이션까지 함께 봐야 하는 변경이다.
*/
enum class EmotionType(
val label: String,
) {
JOY("기쁨"),
SADNESS("슬픔"),
ANGER("분노"),
ANXIETY("불안"),
GRUMPY("까칠"),
WARM("다정"),
QUIRKY("엉뚱"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import com.nexters.gamss.emotion.domain.EmotionType
import com.nexters.gamss.llm.error.CommentGenerationFailedException

/**
* 프롬프트가 실제로 쓰는 로마자 캐릭터 id(gippeum, dajeong...) <-> 내부 [EmotionType] 매핑.
* EmotionType.label(기쁨/다정/분노/불안/까칠/엉뚱)과 1:1 대응된다. 프롬프트 어휘가 도메인 enum(EmotionType)
* 프롬프트가 실제로 쓰는 로마자 캐릭터 id(gippeum, seulpeum...) <-> 내부 [EmotionType] 매핑.
* EmotionType.label(기쁨/슬픔/분노/불안/까칠/엉뚱)과 1:1 대응된다. 프롬프트 어휘가 도메인 enum(EmotionType)
* 안으로 새어 들어오지 않도록 이 파일과 [PromptProvider], [CommentFeedJsonParser] 안에서만 번역한다.
*/
enum class PromptCharacterId(
val emotionType: EmotionType,
) {
GIPPEUM(EmotionType.JOY),
DAJEONG(EmotionType.WARM),
SEULPEUM(EmotionType.SADNESS),
BUNNO(EmotionType.ANGER),
BULAN(EmotionType.ANXIETY),
KKACHIL(EmotionType.GRUMPY),
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- 출시 전 내부 테스트로 쌓인 대화·카드·생성 이력을 비운다.
--
-- EmotionType에서 다정이(WARM)를 제거했기 때문에, 그 값으로 저장된 행이 남아 있으면
-- 읽는 순간 @Enumerated(EnumType.STRING) 역직렬화가 실패한다(카드·대화 조회, 백오피스
-- 감정 집계가 모두 이 경로다). 실사용자 배포 전이라 보존할 데이터가 없어 정리로 대응한다.
--
-- ⚠️ 다정이 행만 골라 지우면 안 된다. messages.replies_to_message_id·root_message_id가
-- 사라진 메시지를 가리켜 스레드가 끊긴다(다정이 댓글 -> 유저 답글 -> 캐릭터 재응답).
-- 그래서 네 테이블을 통째로 비운다 — 서로를 참조하는 컬럼이 이 안에만 있어
-- (cards.conversation_id, generation_log.conversation_id, messages의 두 참조)
-- 함께 비우면 끊어진 참조가 남지 않는다.
--
-- generation_log는 감정 컬럼이 없어 역직렬화와 무관하지만 함께 비운다. 남겨두면 대화가
-- 0인데 생성 이력만 있는 상태가 되고, 대시보드 지표와 일일 토큰 상한이 이 테이블만 보기
-- 때문에 새 캐릭터 구성 기준으로 다시 쌓는 편이 해석하기 쉽다.
--
-- 회원·소셜 계정·프롬프트 설정은 남긴다(로그인과 프롬프트가 그대로 유지된다).
-- 새로 만든 환경에서는 대상 행이 없어 아무 일도 하지 않는다.

DELETE FROM messages;
DELETE FROM cards;
DELETE FROM conversations;
DELETE FROM generation_log;
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class CardCalendarResponseTest {
Card(
memberId = 1L,
conversationId = 2L,
emotion = EmotionType.WARM,
emotion = EmotionType.SADNESS,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
summary = "요약",
message = "대사",
conversationCreatedAt = dawnInstant,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ class ConversationControllerIntegrationTest {
content =
"""
{"content":"오늘 억울한 일이 있었어",
"excludeCharacters":["ANGER","ANXIETY","GRUMPY","WARM","QUIRKY"]}
"excludeCharacters":["SADNESS","ANGER","ANXIETY","GRUMPY","QUIRKY"]}
""".trimIndent()
}.andExpect {
status { isOk() }
Expand All @@ -313,7 +313,7 @@ class ConversationControllerIntegrationTest {

val conversation = conversationRepository.findAll().single()
assertEquals(
listOf(EmotionType.ANGER, EmotionType.ANXIETY, EmotionType.GRUMPY, EmotionType.WARM, EmotionType.QUIRKY),
listOf(EmotionType.SADNESS, EmotionType.ANGER, EmotionType.ANXIETY, EmotionType.GRUMPY, EmotionType.QUIRKY),
conversation.excludedEmotionTypes.values,
)
}
Expand All @@ -329,7 +329,7 @@ class ConversationControllerIntegrationTest {
content =
"""
{"content":"오늘 억울한 일이 있었어",
"excludeCharacters":["JOY","ANGER","ANXIETY","GRUMPY","WARM","QUIRKY"]}
"excludeCharacters":["JOY","SADNESS","ANGER","ANXIETY","GRUMPY","QUIRKY"]}
""".trimIndent()
}.andExpect {
status { isBadRequest() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ class MessageTest {
Message(
conversationId = 1L,
senderType = SenderType.CHARACTER,
emotionType = EmotionType.WARM,
emotionType = EmotionType.SADNESS,
content = "많이 힘들었겠다",
)

assertEquals(EmotionType.WARM, message.emotionType)
assertEquals(EmotionType.SADNESS, message.emotionType)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class CommentGenerationServiceTest {
dailyTokenLimitService,
)

private val characters = listOf(EmotionType.JOY, EmotionType.WARM, EmotionType.GRUMPY)
private val characters = listOf(EmotionType.JOY, EmotionType.SADNESS, EmotionType.GRUMPY)
private val tikitakaCount = 3

private fun rootMessage(): Message = Message(conversationId = 10L, senderType = SenderType.USER, content = "오늘 억울한 일이 있었어")
Expand Down Expand Up @@ -92,7 +92,7 @@ class CommentGenerationServiceTest {
private fun feed(): CommentFeed =
CommentFeed(
comments = characters.map { CommentDraft(it, "댓글-$it") },
tikitaka = listOf(TikitakaDraft(EmotionType.JOY, EmotionType.WARM, "티키타카")),
tikitaka = listOf(TikitakaDraft(EmotionType.JOY, EmotionType.SADNESS, "티키타카")),
)

private fun promptContext(
Expand Down Expand Up @@ -252,7 +252,7 @@ class CommentGenerationServiceTest {
Message(conversationId = 10L, senderType = SenderType.CHARACTER, emotionType = EmotionType.JOY, content = "댓글")
.also { ReflectionTestUtils.setField(it, "id", 10L) }
val otherComment =
Message(conversationId = 10L, senderType = SenderType.CHARACTER, emotionType = EmotionType.WARM, content = "댓글2")
Message(conversationId = 10L, senderType = SenderType.CHARACTER, emotionType = EmotionType.SADNESS, content = "댓글2")
.also { ReflectionTestUtils.setField(it, "id", 11L) }
val tikitaka =
Message(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class MessageThreadOrderTest {
val comment1 = message(1L, SenderType.CHARACTER, emotionType = EmotionType.JOY)
val comment2 = message(2L, SenderType.CHARACTER, emotionType = EmotionType.ANGER)
val userReplyToComment1 = message(3L, SenderType.USER, repliesToMessageId = 1L)
val characterReplyToUserReply = message(4L, SenderType.CHARACTER, repliesToMessageId = 3L, emotionType = EmotionType.WARM)
val characterReplyToUserReply = message(4L, SenderType.CHARACTER, repliesToMessageId = 3L, emotionType = EmotionType.SADNESS)

val result =
MessageThreadOrder.reorderTikitakaAfterTarget(listOf(comment1, comment2, userReplyToComment1, characterReplyToUserReply))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import kotlin.test.assertFailsWith

class CommentFeedValidatorTest {
private val validator = CommentFeedValidator()
private val characters = listOf(EmotionType.JOY, EmotionType.WARM, EmotionType.GRUMPY)
private val characters = listOf(EmotionType.JOY, EmotionType.SADNESS, EmotionType.GRUMPY)
private val tikitakaCount = 3

private fun validFeed() =
CommentFeed(
comments = characters.map { CommentDraft(it, "댓글-$it") },
tikitaka =
listOf(
TikitakaDraft(EmotionType.JOY, EmotionType.WARM, "티키타카1"),
TikitakaDraft(EmotionType.WARM, EmotionType.GRUMPY, "티키타카2"),
TikitakaDraft(EmotionType.JOY, EmotionType.SADNESS, "티키타카1"),
TikitakaDraft(EmotionType.SADNESS, EmotionType.GRUMPY, "티키타카2"),
TikitakaDraft(EmotionType.GRUMPY, EmotionType.JOY, "티키타카3"),
),
)
Expand Down Expand Up @@ -70,7 +70,7 @@ class CommentFeedValidatorTest {

@Test
fun `tikitaka 개수가 요청한 개수와 다르면 실패한다`() {
val feed = validFeed().copy(tikitaka = listOf(TikitakaDraft(EmotionType.JOY, EmotionType.WARM, "한 개뿐")))
val feed = validFeed().copy(tikitaka = listOf(TikitakaDraft(EmotionType.JOY, EmotionType.SADNESS, "한 개뿐")))

assertFailsWith<CommentGenerationFailedException> { validator.validate(feed, characters, tikitakaCount) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.nexters.gamss.llm.prompt

import com.nexters.gamss.emotion.domain.EmotionType
import com.nexters.gamss.llm.error.CommentGenerationFailedException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class PromptCharacterIdTest {
@Test
fun `모든 감정 캐릭터가 프롬프트 id를 가진다`() {
// 매핑이 빠진 채로 of()를 부르면 NoSuchElementException으로 죽는다.
EmotionType.entries.forEach { emotionType ->
assertEquals(emotionType, PromptCharacterId.of(emotionType).emotionType, "$emotionType 의 프롬프트 id가 없다")
}
}

@Test
fun `슬픔이는 seulpeum으로 번역된다`() {
assertEquals("seulpeum", PromptCharacterId.of(EmotionType.SADNESS).promptId)
assertEquals(EmotionType.SADNESS, PromptCharacterId.fromPromptId("seulpeum").emotionType)
}

@Test
fun `모르는 프롬프트 id는 생성 실패로 던진다`() {
assertFailsWith<CommentGenerationFailedException> { PromptCharacterId.fromPromptId("unknown") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull

/**
* V23 시딩이 이관 전 코드 기본값과 **바이트 단위로 동일**한지 고정한다. 골든 파일은 이관 직전
* PromptProvider.defaultPrompt() 출력 그대로다 — 이 테스트가 깨지면 이관 과정에서 프롬프트가
* 변형된 것이므로 시딩 SQL을 의심해야 한다.
* 마이그레이션을 다 적용한 뒤의 프롬프트가 골든 파일과 **바이트 단위로 동일**한지 고정한다.
* 골든 파일의 출발점은 V23 이관 직전 PromptProvider.defaultPrompt() 출력이고, 이후 프롬프트를
* 바꾸는 마이그레이션(V26 등)이 생기면 골든 파일도 함께 갱신한다 — 이 테스트가 깨지면
* 마이그레이션이 의도와 다른 문자열을 넣은 것이므로 해당 SQL을 의심해야 한다.
*/
class PromptSeedIntegrationTest : RepositoryTest() {
@Autowired
Expand All @@ -21,7 +22,7 @@ class PromptSeedIntegrationTest : RepositoryTest() {
lateinit var promptRevisionRepository: PromptRevisionRepository

@Test
fun `V23 시딩 프롬프트는 이관 전 코드 기본값과 동일하다`() {
fun `마이그레이션이 적용된 프롬프트는 골든 파일과 동일하다`() {
PromptType.entries.forEach { type ->
val row = llmSettingsRepository.findByPromptType(type)
assertNotNull(row, "$type 행이 시딩돼야 한다")
Expand Down
2 changes: 1 addition & 1 deletion src/test/resources/prompts/CARD.default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
예) 비 오는 날 유저가 짜증 → 분노: "얘 오늘 건들면 안 됨."
[캐릭터별 '대변 방식' — 말투는 위 보이스카드, 전달 방식은 아래로. 서로 확실히 달라야 한다]
- gippeum(기쁨): 남들한테 유저를 자랑하고 띄운다. "얘 오늘 좀 빛나니까 다들 봐둬!"
- dajeong(다정): 남들한테 유저를 감싸고 부탁한다. "얘 오늘 좀 여리니까 다들 살살 대해줘…"
- seulpeum(슬픔): 남들한테 유저 대신 울어주며 알린다. "얘 오늘 진짜 많이 힘들었어… 다들 좀 알아줘ㅜㅜ"
- bunno(분노): 남들한테 경고하고 방어벽을 친다. "얘 오늘 건들면 안 됨."
- bulan(불안): 남들한테 노심초사하며 조심시킨다. "얘 오늘 위태로운데 다들 조심 좀…? 괜찮겠지??"
- kkachil(까칠): 남들한테 시크하게 툭 던지되 끝에 은근 챙긴다. "얘 오늘 예민하니까 알아서들 피하든가."
Expand Down
2 changes: 1 addition & 1 deletion src/test/resources/prompts/COMMENT.default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- 등장 캐릭터와 tikitaka 개수는 [이번 응답 조건]에서 지정한다 — 임의로 고르거나 개수를 바꾸지 마라. comments 배열은 지정된 character_id 목록과 정확히 일치해야 한다(추가·누락 금지).
- tikitaka는 comments에 있는 캐릭터끼리만, 지정된 개수만큼. reply_to는 comments 속 캐릭터 중 하나, 자문자답 금지(character_id != reply_to).
- 각 대댓글의 text는 reply_to가 실제로 한 말(단어·주장·톤)에 구체적으로 반응해야 한다. reply_to를 안 읽은 것처럼 자기 성격만 드러내는 일반적인 말은 금지.
- 진영이 다른 캐릭터끼리 부딪히면 더 재밌다(어르는: dajeong/gippeum, 긁는: bunno/bulan/kkachil). eongttung은 진영과 무관하게 뜬금없이 낀다.
- 진영이 다른 캐릭터끼리 부딪히면 더 재밌다(어르는: gippeum/seulpeum, 긁는: bunno/bulan/kkachil). eongttung은 진영과 무관하게 뜬금없이 낀다.
출력 JSON(정확히 이 형태):
{"comments": [{"character_id":"...","text":"..."}, ...],
"tikitaka": [{"character_id":"...","reply_to":"...","text":"..."}, ...]}
Loading
Loading