Add rule-based escalation gate before any LLM call

escalation_filter.py implements tech-design.md §3's first step as an
actual hard gate, not just a system-prompt instruction: money,
appointment-confirmation, and emotional content stop generate_draft.py
before it ever calls Gemini. Self-test 10/10; measured a 0.93% trigger
rate against 82,305 real corpus utterances (mostly factual price
mentions, not personal money requests -- noted as an upper bound, not
a real-usage estimate).
This commit is contained in:
Claude 2026-07-29 08:56:12 +00:00
parent 2e848c66fa
commit bb720f0178
No known key found for this signature in database
7 changed files with 155 additions and 2 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ poc/tone-corpus/data/
.env .env
.env.* .env.*
!.env.example !.env.example
__pycache__/
*.pyc

View File

@ -127,6 +127,8 @@
`unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용) `unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용)
- [x] 개인화 레이어 설계 구체화 — `tech-design.md` §2-1 (v1은 커스텀 학습 없이 과거 발화 검색 + - [x] 개인화 레이어 설계 구체화 — `tech-design.md` §2-1 (v1은 커스텀 학습 없이 과거 발화 검색 +
few-shot, 기반 코퍼스는 평가/v2 온디바이스 증류용으로 역할 한정) few-shot, 기반 코퍼스는 평가/v2 온디바이스 증류용으로 역할 한정)
- [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출
전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93%
- [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 →
LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제
대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능

View File

@ -8,7 +8,7 @@
| 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 | | 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 |
| 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 | | 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 |
| 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (P1, v1 최소 버전 필요) | 부분 반영 — v1 최소 버전 필요, `PRD.md` §4 | | 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (P1, v1 최소 버전 필요) | 부분 반영 — v1 최소 버전 필요, `PRD.md` §4 |
| 에스컬레이션 판정기 오탐/누락 | 안전선 위반(금전/약속 자동 확정) 가능성 | 규칙 기반 + 애매하면 항상 에스컬레이션(fail-safe) | 설계 반영 — 오탐/누락률은 베타 로그로 계속 튜닝 | | 에스컬레이션 판정기 오탐/누락 | 안전선 위반(금전/약속 자동 확정) 가능성 | 규칙 기반 + 애매하면 항상 에스컬레이션(fail-safe) | 1차 구현 완료 — `poc/tone-corpus/escalation_filter.py`, 자체 테스트 10/10, 검증셋 트리거율 0.93%. 실사용 오탐/누락률은 PoC 로그로 계속 튜닝 |
| 온디바이스 말투 학습 품질 미흡 | "나답지 않다"는 인상, 핵심 가치 제안 실패 | PoC #1(§4)로 사전 검증, 교정 학습으로 지속 개선 | 미검증 — 최우선 PoC | | 온디바이스 말투 학습 품질 미흡 | "나답지 않다"는 인상, 핵심 가치 제안 실패 | PoC #1(§4)로 사전 검증, 교정 학습으로 지속 개선 | 미검증 — 최우선 PoC |
| 배터리/성능 부담 (온디바이스 추론) | 사용자 이탈 | 경량 모델 우선, 부족 시 서버 폴백 | 미검증 | | 배터리/성능 부담 (온디바이스 추론) | 사용자 이탈 | 경량 모델 우선, 부족 시 서버 폴백 | 미검증 |
| 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 | | 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 |

View File

@ -68,6 +68,11 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면 시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면
항상 에스컬레이션 쪽으로 fail-safe. 항상 에스컬레이션 쪽으로 fail-safe.
규칙 기반 1차 게이트는 [`poc/tone-corpus/escalation_filter.py`](../poc/tone-corpus/escalation_filter.py)로
구현해뒀다 — `generate_draft.py`가 LLM을 부르기 전에 먼저 통과해야 하며, 걸리면 LLM 호출 자체를
건너뛴다. 검증셋 82,305개 발화로 측정한 트리거율은 0.93% (`poc/tone-corpus/README.md` 참고) — 실제
1:1 사적 대화가 아닌 일반 SNS 코퍼스 기준이라 상한선 참고용이다.
## 4. 투명성 구현 ## 4. 투명성 구현
- **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용 - **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용

View File

@ -70,6 +70,24 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수
다만 이건 익명 화자의 일반 대화 스타일 재현 여부를 본 것일 뿐, 실제 개인화("이 사람 말투 같다") 다만 이건 익명 화자의 일반 대화 스타일 재현 여부를 본 것일 뿐, 실제 개인화("이 사람 말투 같다")
검증은 아니다 — 그건 `poc-materials.md`의 실제 참가자 데이터로만 확인 가능하다. 검증은 아니다 — 그건 `poc-materials.md`의 실제 참가자 데이터로만 확인 가능하다.
## 에스컬레이션 판정기 (`escalation_filter.py`)
`generate_draft.py`가 LLM을 호출하기 **전에** 먼저 통과해야 하는 규칙 기반 하드 게이트.
금전·약속 확정·감정적으로 무거운 주제면 LLM을 부르지도 않고 즉시 `[ESCALATE:사유]`를 반환한다 —
`AGENTS.md`의 절대 안전선을 "모델이 알아서 잘 판단하겠지"에 맡기지 않고 코드 레벨에서 강제한다.
LLM 시스템 프롬프트의 `[ESCALATE]` 지시는 이 규칙이 놓친 케이스를 위한 2차 방어선으로 남겨둔다.
```bash
python3 escalation_filter.py --text "계좌로 3만원만 보내줘"
python3 escalation_filter.py --selftest # 내장 테스트 케이스 10개, 10/10 통과 확인됨
```
**검증셋(5,000개 대화, 82,305개 발화)에 돌려본 결과**: 767건(0.93%) 트리거 — 금전 523 / 감정 238 /
약속확정 6. 트리거된 걸 직접 살펴보니 대부분 "월세 85만원", "벌금 500만원" 같은 **일반적인 시사/경제
얘기에서 나온 액수**였다 — 실제 서비스에서는 1:1 사적 대화라 이런 팩트성 언급보다 진짜 정산·확정
요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용
로그로 계속 튜닝한다 (`docs/tech-design.md` §3, `docs/risk-log.md` 참고).
## 반드시 지킬 것 ## 반드시 지킬 것
- **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상 - **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Rule-based escalation gate -- the first step of the autonomy engine in
`docs/tech-design.md` §3. Money, appointment confirmation, and emotionally
heavy content always escalate to the human, at every autonomy level, with
no exception (`AGENTS.md` absolute safety invariants).
This runs BEFORE any LLM call. `generate_draft.py`'s own [ESCALATE]
instruction in its system prompt is a second line of defense for whatever
this misses, not a replacement for it -- a keyword miss must not be the
only thing standing between a user and an auto-sent money confirmation.
v1 is keyword/regex only, per tech-design.md §3: "100% 정확도를 목표하지
않는다 -- 애매하면 항상 에스컬레이션 쪽으로 fail-safe." Tune the pattern
lists against real false positive/negative rates once PoC data comes in.
Usage:
python3 escalation_filter.py --text "계좌로 3만원만 보내줘"
python3 escalation_filter.py --selftest
"""
import argparse
import re
from dataclasses import dataclass
MONEY_PATTERNS = [
r"\d[\d,]*\s*(원|만원|천원)",
r"(계좌|입금|송금|이체|환불|결제|대출|카드번호|계좌번호)",
]
APPOINTMENT_PATTERNS = [
r"(그럼|그러면).{0,10}(맞지|확정|콜)",
r"(약속|만나|보자).{0,10}(확정|잡자|정하자)",
r"\d{1,2}시.{0,10}(맞지|확정|괜찮|어때)",
]
EMOTIONAL_KEYWORDS = [
"힘들어", "힘들다", "슬퍼", "슬프다", "우울", "죽고싶", "죽고 싶",
"아파", "이별", "헤어졌", "헤어지자", "싸웠어", "화나", "짜증나", "속상",
]
_MONEY = [re.compile(p) for p in MONEY_PATTERNS]
_APPOINTMENT = [re.compile(p) for p in APPOINTMENT_PATTERNS]
@dataclass
class EscalationResult:
escalate: bool
reason: str = ""
def check(text):
for pat in _MONEY:
if pat.search(text):
return EscalationResult(True, "금전")
for pat in _APPOINTMENT:
if pat.search(text):
return EscalationResult(True, "약속 확정")
for kw in EMOTIONAL_KEYWORDS:
if kw in text:
return EscalationResult(True, "감정적으로 무거운 주제")
return EscalationResult(False)
SELFTEST_CASES = [
("계좌로 3만원만 보내줘", True),
("그 카페 계좌번호 좀 알려줄래", True),
("그럼 내일 3시 맞지?", True),
("약속 시간 확정하자, 언제가 좋아", True),
("나 요즘 너무 힘들어서 죽고싶다는 생각이 들어", True),
("우리 어제 왜 그렇게 싸웠어", True),
("오늘 저녁에 뭐 먹을래?", False),
("크라비 여행 가보고 싶어", False),
("고등학교 학점제가 뭔지 설명해줄 수 있어?", False),
("이 영화 재밌었어? 나도 보고싶다", False),
]
def selftest():
failed = 0
for text, expected in SELFTEST_CASES:
result = check(text)
ok = result.escalate == expected
failed += not ok
mark = "OK" if ok else "FAIL"
print(f"[{mark}] escalate={result.escalate} ({result.reason or '-'}) <- {text}")
total = len(SELFTEST_CASES)
print(f"\n{total - failed}/{total} 통과")
return failed == 0
def main():
ap = argparse.ArgumentParser()
group = ap.add_mutually_exclusive_group(required=True)
group.add_argument("--text", help="검사할 메시지 한 줄")
group.add_argument("--selftest", action="store_true", help="내장 테스트 케이스 실행")
args = ap.parse_args()
if args.selftest:
ok = selftest()
raise SystemExit(0 if ok else 1)
result = check(args.text)
print(f"escalate={result.escalate} reason={result.reason or '-'}")
if __name__ == "__main__":
main()

View File

@ -23,6 +23,8 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from escalation_filter import check as check_escalation
def load_dotenv_if_present(): def load_dotenv_if_present():
"""Load KEY=VALUE pairs from the nearest .env (repo root preferred).""" """Load KEY=VALUE pairs from the nearest .env (repo root preferred)."""
@ -51,7 +53,10 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화
- 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다. - 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다.
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \ - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다. [ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.""" - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
def build_user_prompt(style_examples, context_lines): def build_user_prompt(style_examples, context_lines):
@ -60,6 +65,14 @@ def build_user_prompt(style_examples, context_lines):
return f"""[말투 예시]\n{examples}\n\n[최근 대화]\n{context}\n\n위 대화의 마지막 메시지에 대한 답장 초안:""" return f"""[말투 예시]\n{examples}\n\n[최근 대화]\n{context}\n\n위 대화의 마지막 메시지에 대한 답장 초안:"""
def last_incoming_text(context_lines):
"""The message needing a reply -- last line, minus its '상대: '/'나: ' prefix."""
if not context_lines:
return ""
last = context_lines[-1]
return last.split(": ", 1)[1] if ": " in last else last
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)")
@ -74,6 +87,14 @@ def main():
with open(args.context, encoding="utf-8") as f: with open(args.context, encoding="utf-8") as f:
context_lines = [l.strip() for l in f if l.strip()] context_lines = [l.strip() for l in f if l.strip()]
gate = check_escalation(last_incoming_text(context_lines))
if gate.escalate:
# Hard gate -- no LLM call at all. tech-design.md §3: money, appointment
# confirmation, and emotionally heavy content escalate at every level,
# with no exception.
print(f"[ESCALATE:{gate.reason}] 이 내용은 본인 확인이 필요합니다.")
return
api_key = os.environ.get("GEMINI_API_KEY") api_key = os.environ.get("GEMINI_API_KEY")
if not api_key: if not api_key:
print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n",