Skip to content
Merged
93 changes: 72 additions & 21 deletions admin-web/src/pages/llm-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,72 @@ import { PageHeader } from '@/components/page-header'
import { PromptRevisionHistory } from '@/pages/prompt-revision-history'
import { cn } from '@/lib/utils'

type PromptType = 'COMMON' | 'COMMENT' | 'REPLY' | 'CARD' | 'EONGTTUNG_TOPIC'
type PromptType = 'COMMON' | 'COMMENT' | 'REPLY' | 'CARD' | 'CARD_EMOTION' | 'EONGTTUNG_TOPIC'

const TABS: { value: PromptType; label: string; hint: string }[] = [
{ value: 'COMMON', label: '공통', hint: '세 타입이 공유하는 톤·경계·말맛지침·보이스카드. 여기를 바꾸면 댓글·답글·카드에 모두 반영됩니다.' },
{ value: 'COMMENT', label: '댓글', hint: '여러 감정 캐릭터가 일기에 코멘트를 달고 서로 티키타카하는 생성.' },
{ value: 'REPLY', label: '답글', hint: '유저가 캐릭터 댓글에 답글을 달면 그 캐릭터 1명이 재응답하는 생성.' },
{ value: 'CARD', label: '카드', hint: '대화 종료 시 대표 캐릭터가 유저를 대신해 남기는 한 줄 카드 대사.' },
{ value: 'EONGTTUNG_TOPIC', label: '엉뚱이 소재', hint: '엉뚱이가 꺼낼 소재 목록. 한 줄에 하나씩 적으면 생성 시 서버가 무작위로 한 줄을 고릅니다.' },
]
interface PromptTab {
value: PromptType
/** 탭 버튼에 보이는 이름. */
label: string
/** 탭 아래 한 줄 설명 — 이 프롬프트가 무엇을 만드는지. */
hint: string
/** 편집기 위 라벨. */
editorLabel: string
/** 편집기 아래 주의사항. */
editorNote: string
/** 공통 프롬프트와 조립되지 않고 단독으로 쓰이는 타입(조립 안내 배너를 숨긴다). */
standalone?: boolean
}

/**
* 탭 정의. `Record<PromptType, ...>`이라 타입을 하나 늘리고 탭을 빠뜨리면 컴파일이 막힌다 —
* 배열로 두면 누락돼도 조회가 undefined를 돌려주며 라벨이 조용히 빈 칸으로 렌더된다.
*/
const TABS: Record<PromptType, PromptTab> = {
COMMON: {
value: 'COMMON',
label: '공통',
hint: '댓글·답글·카드가 공유하는 톤·경계·말맛지침·보이스카드. 여기를 바꾸면 그 세 타입에 모두 반영됩니다(카드 감정·엉뚱이 소재는 제외).',
editorLabel: '공통 프롬프트',
editorNote: '캐릭터 보이스카드·말맛지침 등 댓글·답글·카드가 공유하는 부분입니다. 신중히 수정하세요.',
},
COMMENT: {
value: 'COMMENT',
label: '댓글',
hint: '여러 감정 캐릭터가 일기에 코멘트를 달고 서로 티키타카하는 생성.',
editorLabel: '타입 프롬프트',
editorNote: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.',
},
REPLY: {
value: 'REPLY',
label: '답글',
hint: '유저가 캐릭터 댓글에 답글을 달면 그 캐릭터 1명이 재응답하는 생성.',
editorLabel: '타입 프롬프트',
editorNote: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.',
},
CARD: {
value: 'CARD',
label: '카드',
hint: '대화 종료 시 대표 캐릭터가 유저를 대신해 남기는 한 줄 카드 대사.',
editorLabel: '타입 프롬프트',
editorNote: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.',
},
CARD_EMOTION: {
value: 'CARD_EMOTION',
label: '카드 감정',
hint: '카드 생성 요청에 감정이 없을 때, 유저가 보낸 메시지만 보고 감정 6종 중 하나를 고르는 분류.',
editorLabel: '감정 분류 프롬프트',
editorNote: '분류 작업이라 공통 프롬프트와 조립하지 않고 단독으로 쓰입니다. 응답은 감정 6종으로 강제됩니다.',
standalone: true,
},
EONGTTUNG_TOPIC: {
value: 'EONGTTUNG_TOPIC',
label: '엉뚱이 소재',
hint: '엉뚱이가 꺼낼 소재 목록. 한 줄에 하나씩 적으면 생성 시 서버가 무작위로 한 줄을 고릅니다.',
editorLabel: '소재 목록 (한 줄에 하나)',
editorNote: '빈 줄은 무시됩니다. 목록을 전부 비우면 저장할 수 없습니다.',
standalone: true,
},
}

function SavedFlash({ show }: { show: boolean }) {
if (!show) {
Expand Down Expand Up @@ -131,7 +188,7 @@ function ModelSection() {
{error && <span className="text-sm text-destructive">저장에 실패했습니다</span>}
</div>
<p className="text-xs text-muted-foreground">
댓글·답글·카드 생성이 모두 이 모델을 씁니다. Gemini API에서 사용 가능한 모델을 자동으로 불러옵니다.
댓글·답글·카드 생성과 카드 감정 분류가 모두 이 모델을 씁니다. Gemini API에서 사용 가능한 모델을 자동으로 불러옵니다.
</p>
</>
)}
Expand Down Expand Up @@ -171,7 +228,7 @@ function PromptSection() {

const dirty = prompt.trim() !== savedPrompt.trim()
const ready = Boolean(settings) && settings?.promptType === type
const activeTab = TABS.find((t) => t.value === type)
const activeTab = TABS[type]

const onSave = () => {
if (!dirty || !prompt.trim()) {
Expand All @@ -193,7 +250,7 @@ function PromptSection() {

return (
<div className="space-y-4">
{type !== 'EONGTTUNG_TOPIC' && (
{!activeTab.standalone && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 text-xs text-muted-foreground">
<Layers className="size-4 shrink-0 text-muted-foreground/70" />
<span>
Expand All @@ -205,7 +262,7 @@ function PromptSection() {
)}

<div className="inline-flex items-center rounded-lg border bg-muted/40 p-1">
{TABS.map((tab) => (
{Object.values(TABS).map((tab) => (
<button
key={tab.value}
type="button"
Expand All @@ -222,7 +279,7 @@ function PromptSection() {
))}
</div>

{activeTab && <p className="text-sm text-muted-foreground">{activeTab.hint}</p>}
<p className="text-sm text-muted-foreground">{activeTab.hint}</p>

{isError && !settings ? (
<Card className="flex flex-col items-start gap-3 p-6">
Expand All @@ -240,7 +297,7 @@ function PromptSection() {
<Card className="space-y-3 p-6">
<div className="flex items-baseline justify-between">
<label htmlFor="prompt" className="text-sm font-medium">
{type === 'COMMON' ? '공통 프롬프트' : type === 'EONGTTUNG_TOPIC' ? '소재 목록 (한 줄에 하나)' : '타입 프롬프트'}
{activeTab.editorLabel}
</label>
<span className="text-xs tabular-nums text-muted-foreground">{prompt.length.toLocaleString()}자</span>
</div>
Expand All @@ -252,13 +309,7 @@ function PromptSection() {
className="min-h-[28rem] font-mono text-[13px] leading-relaxed"
/>
<div className="flex flex-wrap items-center gap-2">
<p className="mr-auto text-xs text-muted-foreground">
{type === 'COMMON'
? '캐릭터 보이스카드·말맛지침 등 세 타입이 공유하는 부분입니다. 신중히 수정하세요.'
: type === 'EONGTTUNG_TOPIC'
? '빈 줄은 무시됩니다. 목록을 전부 비우면 저장할 수 없습니다.'
: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.'}
</p>
<p className="mr-auto text-xs text-muted-foreground">{activeTab.editorNote}</p>
<SavedFlash show={flash} />
<Button size="sm" onClick={onSave} disabled={!dirty || saving || !prompt.trim()}>
<Save className="size-4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import org.springframework.web.bind.annotation.RestController
description =
"LLM 모델·시스템 프롬프트 조회·수정 (ROLE_ADMIN 필요). 재배포 없이 다음 생성부터 반영. " +
"모델은 앱 전체 단일 설정이고, 프롬프트는 타입별(COMMON 공통 + COMMENT/REPLY/CARD)로 조립된다. " +
"EONGTTUNG_TOPIC은 조립에 쓰이지 않는 엉뚱이 소재 목록(한 줄에 소재 하나)으로, 생성 시 서버가 한 줄을 무작위로 골라 유저 콘텐츠에 넣는다.",
"EONGTTUNG_TOPIC은 조립에 쓰이지 않는 엉뚱이 소재 목록(한 줄에 소재 하나)으로, 생성 시 서버가 한 줄을 무작위로 골라 유저 콘텐츠에 넣는다. " +
"CARD_EMOTION도 조립에 쓰이지 않는다 — 카드 생성 요청에 emotion이 없을 때 유저 메시지만 보고 감정 6종 중 하나를 고르는 분류 프롬프트로 단독 사용된다.",
Comment thread
theminjunchoi marked this conversation as resolved.
)
@Validated
@RestController
Expand All @@ -53,7 +54,7 @@ class AdminLlmSettingsController(
@Operation(
summary = "모델 수정",
description =
"앱 전체 모델을 갱신합니다(댓글·답글·카드 공통).\n\n" +
"앱 전체 모델을 갱신합니다(댓글·답글·카드 생성과 카드 감정 분류 공통).\n\n" +
"**실패 응답**\n\n" +
"| error.code | HTTP | 설명 |\n" +
"|---|---|---|\n" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.nexters.gamss.admin.controller.dto

import io.swagger.v3.oas.annotations.media.Schema

/** 앱 전체 단일 모델 설정. 댓글·답글·카드 생성이 모두 이 모델을 쓴다. */
/** 앱 전체 단일 모델 설정. 댓글·답글·카드 생성과 카드 감정 분류가 모두 이 모델을 쓴다. */
data class ModelSettingResponse(
@field:Schema(description = "현재 적용 중인 모델", example = "gemini-3.1-flash-lite")
val model: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import io.swagger.v3.oas.annotations.media.Schema

/** 타입별 프롬프트 설정(원본, 공통과 조립 전). 과거 버전으로의 복원은 리비전 API가 담당한다. */
data class PromptSettingResponse(
@field:Schema(description = "프롬프트 타입", example = "COMMENT", allowableValues = ["COMMON", "COMMENT", "REPLY", "CARD", "EONGTTUNG_TOPIC"])
@field:Schema(
description = "프롬프트 타입",
example = "COMMENT",
allowableValues = ["COMMON", "COMMENT", "REPLY", "CARD", "CARD_EMOTION", "EONGTTUNG_TOPIC"],
)
val promptType: String,
@field:Schema(description = "현재 적용 중인 시스템 프롬프트(이 타입의 원본)")
val systemPrompt: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ data class UpdatePromptRequest(
@field:Schema(
description = "수정할 프롬프트 타입",
example = "COMMENT",
allowableValues = ["COMMON", "COMMENT", "REPLY", "CARD", "EONGTTUNG_TOPIC"],
allowableValues = ["COMMON", "COMMENT", "REPLY", "CARD", "CARD_EMOTION", "EONGTTUNG_TOPIC"],
)
val promptType: PromptType?,
@field:NotBlank(message = "systemPrompt는 필수입니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import org.springframework.transaction.annotation.Transactional

/**
* LLM 생성 설정 조회·수정. 두 종류를 관리한다:
* - **모델**: 앱 전체 단일 설정(댓글·답글·카드가 같은 모델 사용). 물리적으로는 [PromptType.COMMON] 행에 저장한다.
* - **모델**: 앱 전체 단일 설정(댓글·답글·카드 생성과 카드 감정 분류가 같은 모델 사용).
* 물리적으로는 [PromptType.COMMON] 행에 저장한다.
* - **프롬프트**: [PromptType]별 원본(백오피스 편집 단위). 생성기가 쓰는 조립본(COMMON + 타입)은 [SystemPromptResolver]가 만든다.
*
* 프롬프트의 단일 원본은 DB다 — V23 시딩이 모든 타입의 행을 보장하므로 코드 폴백은 없고,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,40 @@ class AdminLlmSettingsControllerIntegrationTest {
}
}

@Test
fun `카드 감정 프롬프트도 조회·수정되고 리비전이 남는다`() {
// 백오피스 API는 PromptType 제네릭이라 enum·시딩만으로 신규 타입이 동작해야 한다(#134·#135).
mockMvc
.get("/api/admin/llm-settings/prompt?promptType=CARD_EMOTION") {
header(HttpHeaders.AUTHORIZATION, adminBearer())
}.andExpect {
status { isOk() }
jsonPath("$.data.promptType") { value("CARD_EMOTION") }
// 본문 문구가 아니라 "시딩된 값이 있다"까지만 본다 — 프롬프트 튜닝으로 다시 시드되면
// (V26이 캐릭터 개편으로 COMMON·COMMENT·CARD 본문을 통째로 갈아치운 선례가 있다)
// 조회·수정·리비전과 무관한 이유로 이 테스트가 깨진다.
jsonPath("$.data.systemPrompt") { isNotEmpty() }
}

mockMvc
.put("/api/admin/llm-settings/prompt") {
header(HttpHeaders.AUTHORIZATION, adminBearer())
contentType = MediaType.APPLICATION_JSON
content = """{"promptType":"CARD_EMOTION","systemPrompt":"수정한 감정 분류 프롬프트"}"""
}.andExpect {
status { isOk() }
jsonPath("$.data.systemPrompt") { value("수정한 감정 분류 프롬프트") }
}

mockMvc
.get("/api/admin/llm-settings/prompt/revisions?promptType=CARD_EMOTION") {
header(HttpHeaders.AUTHORIZATION, adminBearer())
}.andExpect {
status { isOk() }
jsonPath("$.data.content[0].savedBy") { value("admin@gamss.kr") }
}
}

@Test
fun `인증 없이 리비전 목록을 조회하면 401`() {
mockMvc
Expand Down
Loading