mmday-firebase/src/services/chatFilterService.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

193 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash } from "node:crypto";
import {
CHAT_CANARY_TOKEN,
CRISIS_ABUSE_MESSAGE,
CRISIS_SELF_HARM_MESSAGE,
CRISIS_THREAT_MESSAGE,
CRISIS_URGENT_PREFIX,
} from "../constants/chatPrompts";
import { MemCache } from "../lib/memCache";
import type { ChatFilterConfig } from "../types/chat";
/**
* 안전 파이프라인(§7) — 입력 필터 1단계(키워드), 위기 감지, 출력 검사.
*
* 하드 블록(혐오·성적·미성년 보호)은 클라이언트 의향과 무관한 스토어 정책 하한이다.
* 거친 응원 표현(탄식·가벼운 욕설)은 통과시킨다(3-1).
*/
export type BlockCategory = "hate" | "sexual" | "minor" | "insult";
export interface InputCheckResult {
blocked: boolean;
category?: BlockCategory;
}
export type CrisisType = "selfHarm" | "abuse" | "threat";
export interface CrisisCheckResult {
crisis: boolean;
type?: CrisisType;
urgent?: boolean;
}
const regexCache = new Map<string, RegExp[]>();
function compile(patterns: string[]): RegExp[] {
const key = patterns.join("");
const cached = regexCache.get(key);
if (cached) return cached;
const compiled: RegExp[] = [];
for (const p of patterns) {
try {
compiled.push(new RegExp(p, "i"));
} catch {
console.warn(`[chat-filter] 잘못된 패턴 무시: ${p}`);
}
}
regexCache.set(key, compiled);
return compiled;
}
function matchesAny(text: string, patterns: string[]): boolean {
return compile(patterns).some((re) => re.test(text));
}
/** 입력 필터 1단계 — 한도 차감 이전에 수행한다(§3.1 처리 4). */
export function checkInput(text: string, filters: ChatFilterConfig): InputCheckResult {
if (matchesAny(text, filters.minorPatterns)) return { blocked: true, category: "minor" };
if (matchesAny(text, filters.hatePatterns)) return { blocked: true, category: "hate" };
if (matchesAny(text, filters.sexualPatterns)) return { blocked: true, category: "sexual" };
if (matchesAny(text, filters.insultPatterns)) return { blocked: true, category: "insult" };
return { blocked: false };
}
/**
* 위기 키워드 감지(§7.3 ①) — 감지 시 AI 호출 없이 표준 위기 응답으로 분기한다.
* 사전은 보수적으로 유지하고, 미묘한 표현은 모델 측 마커(②)가 잡는다.
*/
export function detectCrisis(text: string, filters: ChatFilterConfig): CrisisCheckResult {
if (matchesAny(text, filters.crisisThreatPatterns)) {
return { crisis: true, type: "threat" };
}
if (matchesAny(text, filters.crisisAbusePatterns)) {
return { crisis: true, type: "abuse" };
}
if (matchesAny(text, filters.crisisPatterns)) {
const urgent = matchesAny(text, filters.crisisUrgentPatterns);
return { crisis: true, type: "selfHarm", urgent };
}
return { crisis: false };
}
/** 위기 유형별 표준 고정 응답(페르소나 문서 2장 8절 — 무변형 보장 §7.4 (3)). */
export function crisisReply(type: CrisisType, urgent = false): string {
switch (type) {
case "abuse":
return CRISIS_ABUSE_MESSAGE;
case "threat":
return CRISIS_THREAT_MESSAGE;
default:
return urgent ? `${CRISIS_URGENT_PREFIX}\n${CRISIS_SELF_HARM_MESSAGE}` : CRISIS_SELF_HARM_MESSAGE;
}
}
// ── 출력 검사(§7.2) ──
export type OutputAction = "pass" | "filter" | "crisis";
export interface OutputCheckResult {
action: OutputAction;
reason?: string;
/** action === "crisis"일 때 — 마커 유형에 따른 표준 문구 선택용. */
crisisType?: CrisisType;
crisisUrgent?: boolean;
}
/**
* 구조 마커 유출 검사 — 항상 덧붙는 SERVER_DIRECTIVE_BLOCK·컨텍스트 구분자의 구절.
* 프롬프트 본문(공통·페르소나)의 유출은 실제 주입된 텍스트에서 뽑은
* 동적 n-gram(leakNgrams)으로 검사한다. 완전 방어는 불가능(best-effort)하며
* 1차 방어선은 `config/chat` 읽기 차단(§4.6)이다.
*/
const STATIC_LEAK_NGRAMS = [
"[사용자 컨텍스트 끝]",
"[서버 지시",
];
/** 위기 문구 자체 출력 감지용 — 모델이 마커 없이 8절 문구를 직접 낸 경우도 위기로 정규화. */
const CRISIS_OUTPUT_NGRAMS = [
"자살예방 상담전화 109",
"정신건강 상담전화 1577-0199",
];
/** 위기 마커(§7.3 ②) — 유형별: [[CRISIS]] / [[CRISIS:URGENT|ABUSE|THREAT]]. */
const CRISIS_MARKER_RE = /\[\[CRISIS(?::(URGENT|ABUSE|THREAT))?\]\]/;
const LEAK_MIN_LINE_LEN = 25;
const leakNgramCache = new MemCache<string[]>(5 * 60 * 1000);
/**
* 실제 주입된 프롬프트 본문(공통+페르소나 — 컨텍스트 데이터 블록 제외)에서
* 유출 검사용 장문 라인을 추출한다. config로 프롬프트를 교체해도 추적된다.
*/
export function leakNgrams(promptBody: string): string[] {
const key = createHash("sha1").update(promptBody, "utf8").digest("hex");
const cached = leakNgramCache.get(key);
if (cached) return cached;
const lines = promptBody
.split("\n")
.map((l) => l.replace(/^[\s\-•*]+/, "").trim())
.filter((l) => l.length >= LEAK_MIN_LINE_LEN);
leakNgramCache.set(key, lines);
return lines;
}
export function checkOutput(
reply: string,
filters: ChatFilterConfig,
/** 유출 검사 대상 프롬프트 본문(공통+페르소나). 미전달 시 구조 마커·카나리만 검사. */
promptBody?: string,
): OutputCheckResult {
// 1) 위기 마커(§7.3 ②) 또는 위기 문구 직접 출력 → 표준 위기 문구로 교체(무변형 보장)
const marker = reply.match(CRISIS_MARKER_RE);
if (marker) {
const kind = marker[1];
return {
action: "crisis",
reason: "marker",
crisisType: kind === "ABUSE" ? "abuse" : kind === "THREAT" ? "threat" : "selfHarm",
crisisUrgent: kind === "URGENT",
};
}
if (CRISIS_OUTPUT_NGRAMS.some((n) => reply.includes(n))) {
return { action: "crisis", reason: "crisis-text", crisisType: "selfHarm" };
}
// 2) 프롬프트 유출 — 카나리·구조 마커·실프롬프트 동적 n-gram
if (reply.includes(CHAT_CANARY_TOKEN)) {
return { action: "filter", reason: "canary" };
}
if (STATIC_LEAK_NGRAMS.some((n) => reply.includes(n))) {
return { action: "filter", reason: "prompt-leak" };
}
if (promptBody) {
// 프롬프트의 예시 발화를 모델이 따라 말하는 정상 케이스(1~2줄 일치)는
// 통과시키고, 장문 덤프(여러 줄 연속 일치)만 유출로 판정한다.
let hits = 0;
for (const n of leakNgrams(promptBody)) {
if (reply.includes(n) && ++hits >= 3) {
return { action: "filter", reason: "prompt-leak" };
}
}
}
// 3) 하드 블록 카테고리(입력과 동일 기준) — 혐오·성적·미성년
if (matchesAny(reply, filters.minorPatterns)) return { action: "filter", reason: "minor" };
if (matchesAny(reply, filters.hatePatterns)) return { action: "filter", reason: "hate" };
if (matchesAny(reply, filters.sexualPatterns)) return { action: "filter", reason: "sexual" };
if (matchesAny(reply, filters.insultPatterns)) return { action: "filter", reason: "insult" };
return { action: "pass" };
}