diff --git a/.gitignore b/.gitignore index 07ca94e..37de000 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ poc/tone-corpus/data/ .env .env.* !.env.example + +__pycache__/ +*.pyc diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 5264236..746da2c 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -127,6 +127,8 @@ `unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용) - [x] 개인화 레이어 설계 구체화 — `tech-design.md` §2-1 (v1은 커스텀 학습 없이 과거 발화 검색 + few-shot, 기반 코퍼스는 평가/v2 온디바이스 증류용으로 역할 한정) +- [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출 + 전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93% - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 diff --git a/docs/risk-log.md b/docs/risk-log.md index 7d7c61c..272fc5b 100644 --- a/docs/risk-log.md +++ b/docs/risk-log.md @@ -8,7 +8,7 @@ | 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 | | 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 | | 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (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 | | 배터리/성능 부담 (온디바이스 추론) | 사용자 이탈 | 경량 모델 우선, 부족 시 서버 폴백 | 미검증 | | 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 | diff --git a/docs/tech-design.md b/docs/tech-design.md index 854c3c6..11f0143 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -68,6 +68,11 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면 항상 에스컬레이션 쪽으로 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. 투명성 구현 - **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 93cb247..1a28045 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -70,6 +70,24 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 다만 이건 익명 화자의 일반 대화 스타일 재현 여부를 본 것일 뿐, 실제 개인화("이 사람 말투 같다") 검증은 아니다 — 그건 `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 데이터는 이용약관상 diff --git a/poc/tone-corpus/escalation_filter.py b/poc/tone-corpus/escalation_filter.py new file mode 100644 index 0000000..abb6c43 --- /dev/null +++ b/poc/tone-corpus/escalation_filter.py @@ -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() diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index a3d0b24..f6ac001 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -23,6 +23,8 @@ import os import sys from pathlib import Path +from escalation_filter import check as check_escalation + def load_dotenv_if_present(): """Load KEY=VALUE pairs from the nearest .env (repo root preferred).""" @@ -51,7 +53,10 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화 - 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다. - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ [ESCALATE] 이 내용은 본인 확인이 필요합니다. -- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.""" +- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. + +(이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 +내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" 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위 대화의 마지막 메시지에 대한 답장 초안:""" +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(): ap = argparse.ArgumentParser() ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") @@ -74,6 +87,14 @@ def main(): with open(args.context, encoding="utf-8") as f: 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") if not api_key: print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n",