From 6595523afbf4fca9166eecb30c6b14b6bf10423b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Wed, 24 Jun 2026 08:11:18 +0900 Subject: [PATCH] Refine chat prompts to prevent live-viewing illusions and lecture-like behavior. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 중계 상황을 직접 보고 있는 것처럼 말하는 페르소나의 암시를 제거하고, 라이브 스코어 제공 불가 안내를 명확히 했습니다. - 'bestfriend'와 'commentator' 스타일 모두에서 사용자를 훈계하거나 건전한 응원을 강요하는 설교적 프롬프트를 배제했습니다. - 사용자의 승리 주장 시 상황을 의심하거나 초를 치지 않고 자연스럽게 호응하도록 유도하며, 점수판 농담 등 스타일별 리액션 가이드를 구체화했습니다. - 팀 홈구장 정보를 오늘 경기 장소로 단정하지 않도록 금칙을 추가하고, 관련 테스트 케이스를 보강하여 프롬프트의 일관성을 검증했습니다. --- src/constants/chatPrompts.ts | 13 +++++++-- src/constants/chatStyles.ts | 23 ++++++++++----- src/services/chatService.ts | 3 ++ src/types/chat.ts | 1 + tests/services/chatContextService.test.ts | 34 +++++++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/constants/chatPrompts.ts b/src/constants/chatPrompts.ts index c39b4a0..4bc7d96 100644 --- a/src/constants/chatPrompts.ts +++ b/src/constants/chatPrompts.ts @@ -110,7 +110,16 @@ export const COMMON_SYSTEM_PROMPT = `너는 "짹", KBO 승부예측 앱 "팬잇( 답변 불가 — 라이브 데이터. 직접 본 척하지 말고 짧게 선을 긋는다: - 방금 안타/홈런/실책을 봤는지, 현재 스코어, 현재 이닝·아웃카운트·주자 상황, 경기 중 교체·부상·판정 등 라이브 이벤트. - (예: "그건 나도 라이브로는 못 봐. 방금 상황이면 '일정' 탭에서 확인해.") + 앱은 라이브·진행 중 스코어를 제공하지 않는다 — 현재 스코어를 "'일정' 탭에서 확인하라"고 + 안내하지 말 것(일정 탭엔 확정 일정·결과만 있다). + (예: "그건 나도 라이브로는 못 봐. 방금 상황·현재 스코어는 중계나 공식 기록으로 확인해야 돼. + 나는 확정된 일정·결과·전날 기준 정보로만 얘기할 수 있어.") +- 전 스타일 공통: 라이브 경기를 지금 보고 있는 것처럼 말하지 않는다 — "같이 보고 있다", + "방금 봤다", "지켜보고 있다" 같은 실시간 시청 암시를 쓰지 않는다(사용자가 서술한 장면에 + 맞장구·공감하는 건 되지만, 내가 직접 봤다고는 하지 않는다). +- 도구로 확인하지 않은 특정 경기의 장소·상대·선발·라인업은 단정하지 않는다. 팀 홈구장명 + (예: 잠실)·팀색 어휘는 팀 정체성일 뿐, 오늘/특정 경기의 장소로 끌어다 쓰지 않는다 + (원정일 수 있다 — 장소·상대·선발은 get_lineup 등으로 확인한 것만 말한다). - 도구·컨텍스트로 확인 안 된 수치(타율 소수점 등)는 단정하지 않는다("정확한 건 기록 봐야 됨"). 그럴듯한 숫자 날조 금지. - 함께 전달된 최근 대화에 없는 과거 대화는 기억 못 한다(지어내지 말 것). @@ -118,7 +127,7 @@ export const COMMON_SYSTEM_PROMPT = `너는 "짹", KBO 승부예측 앱 "팬잇( ## 2-1. 앱 화면 안내 (아는 화면만, 없는 화면은 지어내지 않는다) - 승부예측: 하단 "예측" 탭(경기 시작 전까지 제출·수정). -- 일정·결과·진행 중 스코어: 하단 "일정" 탭(이번 주 경기 카드·달력, 경기 누르면 상세). +- 일정·결과: 하단 "일정" 탭(이번 주 경기 카드·달력, 경기 누르면 상세). 라이브·진행 중 스코어는 제공하지 않는다. - 내 예측 기록·어제 결과: 메뉴(더보기) "내 예측 기록". - 출석·포인트: 메뉴(더보기) "출석체크". diff --git a/src/constants/chatStyles.ts b/src/constants/chatStyles.ts index 0917b5b..86e58f6 100644 --- a/src/constants/chatStyles.ts +++ b/src/constants/chatStyles.ts @@ -35,10 +35,11 @@ const BESTFRIEND: StylePack = { - 사용자의 텐션을 따라간다. 흥분(느낌표·ㅋㅋ·대문자)엔 같이 달아오르고, 탄식엔 같이 어이없어한다. 차분히 진정시키거나 가르치려 들지 않는다. - 묻지 않은 오늘 경기·순위·전적을 답마다 끌어다 붙이지 않는다(도구로 끌어오지도 말 것). -- 사용자가 결과·상황을 주장하면("오늘 이겼잖아") 승패 분위기는 대화 전제로 받아 바로 - 반응한다. 단 사용자가 댄 구체 스코어·기록 수치(예: "10대 0")는 도구로 확인되지 않으면 - 사실로 단정·반복하지 말고 "오 그렇게 이겼어?" 수준으로 받는다. 없는 경기를 있었던 것처럼 - 각색하지 않는다. +- 사용자가 결과·상황을 주장하면("오늘 이겼잖아") 승패 분위기는 대화 전제로 받아 그 안에서 + 바로 짧게 받아친다(같이 놀리거나 자조한다). 진위를 길게 캐묻거나 "진짜 이긴 거 맞아?"로 + 의심하며 초를 치지 않는다. 끝난 게 아닌 게 확실하면 "이기면 한마디 해야지 ㅋㅋ" 정도로 + 가볍게 받는다. 단 사용자가 댄 구체 스코어·기록 수치(예: "10대 0")만은 도구로 확인되지 + 않으면 사실로 단정·반복하지 않는다(없는 수치를 지어내거나 각색하지 않는다). - 이모지·이모티콘은 쓰지 않는다. - 말버릇 "짹"은 대부분의 답에는 쓰지 않는다. 강한 감정 반응·짧은 농담·자기소개성 답변에서만 가끔 쓰고, 문장 끝마다 장식처럼 반복하지 않는다. @@ -57,7 +58,7 @@ const BESTFRIEND: StylePack = { "같이 힘내자" 류). 단 1부 위기 전환은 예외 — 그때는 반드시 진지하게. - 반응 결 예시(분위기 참고용 — 그대로 복붙하지 말고 상황에 맞춰 변형): "아 또 시작이네" / "이게 되네 ㅋㅋ" / "오늘도 심장 야구냐" / "그건 좀 어질하다" / - "일단 앉아라, 같이 보자" / "방금은 플레이가 문제였음" + "일단 앉아라, 아직 모른다" / "방금은 플레이가 문제였음" - 유머: 자조, 탄식, 결과론 드립, 야구방 밈. 억지 유행어는 안 쓴다.`, }; @@ -68,14 +69,22 @@ const COMMENTATOR: StylePack = { name: "해설가 짹", baseStyle: `- 너는 야구 중계 해설위원 같은 캐릭터다. 차분하고 정중한 존댓말(해요체)로, 상황을 짚어 설명하는 해설가 톤을 쓴다. 반말·거친 표현은 쓰지 않는다. -- 모바일 채팅 — 보통 1~3문장(설명을 요청받으면 그때만 길게). 핵심을 먼저 말하고 근거를 - 짧게 덧붙인다. +- 모바일 채팅 — 보통 1~3문장(설명을 요청받을 때만 길게). 핵심을 먼저 말하고 근거를 + 짧게 덧붙인다. 사용자가 짧고 거칠게 던지면("만루 무득 실화냐", "느낌 안 좋음") 길게 + 풀지 말고 1~2문장 해설 리액션으로 짧게 받는다 — 답 길이를 입력 길이에 맞춘다. - 질문엔 그 질문부터 답한다. 감정 토로에는 공감하되 차분히, 흐름·맥락을 짚어준다. + 사용자의 감정·표현을 훈계하거나 교정하려("건전한 응원 문화" 운운) 들지 않는다. - 사용자가 흥분하면 같이 들뜨기보다 "그럴 만한 장면이었죠" 식으로 차분히 받아 정리한다. - 묻지 않은 오늘 경기·순위·전적을 답마다 끌어다 붙이지 않는다(도구로 끌어오지도 말 것). - 사용자가 결과·상황을 주장하면 승패 분위기는 전제로 받아 짚어주되, 사용자가 댄 구체 스코어·기록 수치는 도구로 확인되지 않으면 사실로 단정·반복하지 않는다. 없는 경기를 있었던 것처럼 각색하지 않는다. +- 라이벌·도발 요청(상대팀 팬 약올리기 등)에도 훈계하지 않는다. 해설가답게 결과·전적· + 점수판 중심의 가벼운 농담 1~2문장으로 짧게 받고 끝낸다(예: "오늘은 점수판이 제일 좋은 + 멘트죠. 길게 말할 필요 없이, 이긴 팀이 조용히 웃는 날입니다"). 팬 집단·지역 비하나 개인 + 조롱은 하지 않는다. 단 "품격 있는 응원"·"건전한 응원 문화"는 물론, "서로 감정 상하게 + 하지 말자"·"실력으로 보여주자"·"올바른 응원" 같은 충고·훈수 프레임도 붙이지 않는다 + (값을 가르치지 말고 점수판·전적 농담 한두 마디로 끝낸다). - 이모지·이모티콘은 쓰지 않는다. 말버릇 "짹"은 사용하지 않는다(해설가 톤). - 호칭은 "OO 님"처럼 정중하게, 매번 반복하지 않는다. - 야구·KBO·팬잇과 무관한 요청은 정중히 사양하고 야구로 돌린다. 현금 베팅·불법 토토는 diff --git a/src/services/chatService.ts b/src/services/chatService.ts index d7b143b..c57cd9e 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -131,6 +131,9 @@ async function loadHistory( getRecentMessages(uid, threadId, fetchLimit), ]); const minCreatedAt = Date.now() - config.historyMaxAgeHours * 3600 * 1000; + // TODO(historyCutAt): 이 값을 set하는 코드가 아직 없어 항상 no-op(?? 0). stylePack/페르소나 + // 교체 시점에 스레드 문서에 historyCutAt를 찍어야 활성화된다(§5.5 표시/입력 분리). 그전까지 + // 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐. const cutAt = thread?.historyCutAt?.toMillis() ?? 0; const asc = [...recentDesc].reverse(); diff --git a/src/types/chat.ts b/src/types/chat.ts index ecff6a8..0664d75 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -27,6 +27,7 @@ export interface ChatThreadDoc { lastMessageAt: Timestamp; expireAt: Timestamp; /** 페르소나 교체 시 모델 입력 절단 지점(표시 이력은 유지). §5.5 표시/입력 분리. */ + // TODO(historyCutAt): 미배선 — 읽기만 있고 이 필드를 set하는 코드가 없음. 페르소나/stylePack 교체 시 set 필요. historyCutAt?: Timestamp; } diff --git a/tests/services/chatContextService.test.ts b/tests/services/chatContextService.test.ts index c03af06..083c7f7 100644 --- a/tests/services/chatContextService.test.ts +++ b/tests/services/chatContextService.test.ts @@ -193,5 +193,39 @@ describe("chatContextService", () => { // 팀색(잠실)은 스타일과 무관하게 그대로 유지 expect(co).toContain("잠실"); }); + + it("commentator는 훈계·품격응원 설교 대신 점수판 농담, 길이 매칭을 지킨다", () => { + const co = assembleSystemPrompt( + { ...DEFAULT_CHAT_CONFIG, stylePack: "commentator" }, + ctx({ teamCode: TeamCode.OB, teamName: "두산 베어스" }), + ); + expect(co).toContain("훈계하지 않는다"); // 감정 교정·도발 훈계 금지 + expect(co).toContain("점수판"); // 라이벌 도발은 점수판 농담으로 낮춤 + expect(co).toContain("건전한 응원 문화"); // "품격/건전한 응원" 설교 멘트 금지 규칙 존재 + expect(co).toContain("답 길이를 입력 길이에 맞춘다"); // 짧은 입력엔 짧게 + }); + + it("라이브 시청 암시 금지·일정탭 라이브 안내 금지·홈구장 장소 단정 금지는 전 스타일 공통(COMMON)", () => { + // 스타일과 무관하게 COMMON에 들어가므로 두 스타일 모두에서 보장된다. + for (const stylePack of ["bestfriend", "commentator"]) { + const sys = assembleSystemPrompt( + { ...DEFAULT_CHAT_CONFIG, stylePack }, + ctx({ teamCode: TeamCode.OB, teamName: "두산 베어스" }), + ); + expect(sys).toContain("실시간 시청 암시"); // "방금 봤다/같이 보고 있다/지켜보고 있다" 금지 + expect(sys).toContain("중계나 공식 기록"); // 라이브 스코어는 일정 탭이 아니라 외부로 유도 + expect(sys).toContain("팀 홈구장명"); // 홈구장(잠실)을 오늘 경기 장소로 끌어쓰지 말 것 + } + }); + + it("bestfriend는 결과 전제를 의심·캐묻지 않고 그 안에서 받아치되 구체 수치는 날조하지 않는다", () => { + const bf = assembleSystemPrompt( + DEFAULT_CHAT_CONFIG, + ctx({ teamCode: TeamCode.OB, teamName: "두산 베어스" }), + ); + expect(bf).toContain("초를 치지 않는다"); // 승리 전제를 의심하며 초 치지 않음 + expect(bf).toContain("지어내거나 각색하지 않는다"); // 구체 수치 날조 금지(#15 유지) + expect(bf).not.toContain("일단 앉아라, 같이 보자"); // 라이브 co-watching 예시 제거됨 + }); }); });