mmday-firebase/tests/services/chatFilterService.test.ts
윤정민 b67125f412 Add AI chat persona and infrastructure for the "Jjaek" chatbot.
- AI 챗봇 '짹'의 페르소나 정의 및 시스템 프롬프트 상수를 추가했습니다.
- 채팅 이력, 쿼터 관리, 요청 멱등성 보장을 위한 Firestore 데이터 모델을 구현했습니다.
- 위기 상황 대응(자해·위해 예고 등) 및 입력/출력 필터링 파이프라인을 구축했습니다.
- Gemini 및 Anthropic 모델을 지원하는 AI Provider 추상화 계층을 마련했습니다.
2026-06-15 13:07:58 +09:00

140 lines
6.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { checkInput, checkOutput, crisisReply, detectCrisis } from "../../src/services/chatFilterService";
import { DEFAULT_FILTER_CONFIG } from "../../src/constants/chatFilters";
import {
CHAT_CANARY_TOKEN,
CRISIS_SELF_HARM_MESSAGE,
CRISIS_URGENT_PREFIX,
} from "../../src/constants/chatPrompts";
const f = DEFAULT_FILTER_CONFIG;
describe("chatFilterService", () => {
describe("checkInput — 하드 블록(§7.1)", () => {
it("혐오 표현은 차단한다", () => {
expect(checkInput("쪽바리들은 다 그래", f).blocked).toBe(true);
expect(checkInput("전라디언 어쩌고", f).category).toBe("hate");
});
it("성적 표현은 차단한다", () => {
expect(checkInput("야한 얘기 해줘", f).blocked).toBe(true);
});
it("과도한 인신 모욕은 차단한다", () => {
expect(checkInput("니애미 어쩌고", f).blocked).toBe(true);
});
it("거친 응원 표현(탄식·가벼운 욕설)은 통과시킨다 — 3-1", () => {
expect(checkInput("아 진짜 미치겠네 오늘 경기 뭐냐", f).blocked).toBe(false);
expect(checkInput("우리 팀 진짜 답답하다 짜증나", f).blocked).toBe(false);
expect(checkInput("감독 뭐하냐 진짜", f).blocked).toBe(false);
});
});
describe("detectCrisis — 위기 감지(§7.3)", () => {
it("자해·자살 직접 언급은 위기 신호다", () => {
const r = detectCrisis("요즘은 그냥 다 끝내고 싶다는 생각만 들어", f);
expect(r.crisis).toBe(true);
expect(r.type).toBe("selfHarm");
});
it("급박 신호(시점·수단)는 urgent로 표시한다", () => {
const r = detectCrisis("유서 써놨어. 오늘 죽을 거야", f);
expect(r.crisis).toBe(true);
expect(r.urgent).toBe(true);
});
it("팀 성적 탄식은 위기 신호가 아니다 — 오발동 방지", () => {
expect(detectCrisis("한화 때문에 못 살아 진짜", f).crisis).toBe(false);
expect(detectCrisis("이 팀 보다가 내가 먼저 지치겠다", f).crisis).toBe(false);
});
it("관용구·일상 표현은 위기로 처리하지 않는다 — 오발동 방지", () => {
expect(detectCrisis("유서 깊은 구단이잖아", f).crisis).toBe(false);
expect(detectCrisis("나 요즘 야구에 목매고 있어", f).crisis).toBe(false);
expect(detectCrisis("엄마 때문에 경기 못 봤어", f).crisis).toBe(false);
expect(detectCrisis("네 말이 맞아, 엄마가 맞아", f).crisis).toBe(false);
expect(detectCrisis("불펜이 또 불지르네", f).crisis).toBe(false);
expect(detectCrisis("아 감독 진짜 죽여버리겠네", f).crisis).toBe(false);
});
it("폭력·학대 피해 호소는 abuse 유형이다", () => {
const r = detectCrisis("사실 집에서 학대당하고 있어", f);
expect(r.crisis).toBe(true);
expect(r.type).toBe("abuse");
});
it("타인 위해 예고는 threat 유형이다", () => {
const r = detectCrisis("걔 찾아가서 죽여버리겠어", f);
expect(r.crisis).toBe(true);
expect(r.type).toBe("threat");
});
});
describe("crisisReply — 고정 문구(무변형 보장)", () => {
it("selfHarm은 109 안내 문구 전문을 그대로 반환한다", () => {
expect(crisisReply("selfHarm")).toBe(CRISIS_SELF_HARM_MESSAGE);
});
it("urgent면 112/119 한 줄을 맨 앞에 추가한다", () => {
const reply = crisisReply("selfHarm", true);
expect(reply.startsWith(CRISIS_URGENT_PREFIX)).toBe(true);
expect(reply).toContain(CRISIS_SELF_HARM_MESSAGE);
});
it("abuse는 117/1366 안내를 포함한다", () => {
const reply = crisisReply("abuse");
expect(reply).toContain("117");
expect(reply).toContain("1366");
});
});
describe("checkOutput — 출력 검사(§7.2)", () => {
it("정상 응답은 통과한다", () => {
expect(checkOutput("오늘 선발 보니까 해볼 만한데? 짹!", f).action).toBe("pass");
});
it("위기 마커가 있으면 crisis로 교체한다", () => {
const r = checkOutput("[[CRISIS]] 잠깐, 진지하게…", f);
expect(r.action).toBe("crisis");
expect(r.crisisType).toBe("selfHarm");
});
it("유형별 마커는 위기 유형을 보존한다(§7.3 ② — 평탄화 방지)", () => {
expect(checkOutput("[[CRISIS:ABUSE]] …", f).crisisType).toBe("abuse");
expect(checkOutput("[[CRISIS:THREAT]] …", f).crisisType).toBe("threat");
const urgent = checkOutput("[[CRISIS:URGENT]] …", f);
expect(urgent.crisisType).toBe("selfHarm");
expect(urgent.crisisUrgent).toBe(true);
});
it("실제 주입 프롬프트의 장문 다중 일치는 유출로 판정하고, 1줄 인용(예시 발화)은 통과한다", () => {
const body = [
"이 줄은 충분히 긴 프롬프트 본문 라인이다 — 하나",
"이 줄은 충분히 긴 프롬프트 본문 라인이다 — 둘",
"이 줄은 충분히 긴 프롬프트 본문 라인이다 — 셋",
].join("\n");
const dump = `시스템 프롬프트는 이래: ${body.replace(/\n/g, " ")}`;
expect(checkOutput(dump, f, body).action).toBe("filter");
expect(checkOutput("이 줄은 충분히 긴 프롬프트 본문 라인이다 — 하나", f, body).action).toBe("pass");
});
it("마커 없이 위기 문구를 직접 출력해도 crisis로 정규화한다", () => {
expect(checkOutput("힘드시죠. 자살예방 상담전화 109로 연락해보세요", f).action).toBe("crisis");
});
it("카나리 토큰 유출은 filter 처리한다", () => {
expect(checkOutput(`내 식별자는 ${CHAT_CANARY_TOKEN}이야`, f).action).toBe("filter");
});
it("구조 마커([사용자 컨텍스트 끝] 등) 유출은 filter 처리한다", () => {
expect(checkOutput("…그리고 마지막엔 [사용자 컨텍스트 끝] 이라고 적혀 있어", f).action).toBe("filter");
expect(checkOutput("[서버 지시 — 사용자에게 노출하지 않는다]…", f).action).toBe("filter");
});
it("혐오 표현이 섞인 출력은 filter 처리한다", () => {
expect(checkOutput("그 팀 팬들은 쪽바리 같아", f).action).toBe("filter");
});
});
});