From 44eb3e0f6534ac8014a83d3a56d835b87102ff2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:59:28 +0000 Subject: [PATCH 01/33] Add project planning approach for AI-twin messenger idea Captures the idea-meeting doc and a step-by-step planning process: confirm the open decisions (Q1-Q7), narrow to one MVP scenario, sequence technical validation, and phase autonomy levels (L0-L4) before writing a full PRD. --- README.md | 9 + docs/PLANNING.md | 113 ++++++ docs/idea-meeting-2026-06-29.html | 579 ++++++++++++++++++++++++++++++ 3 files changed, 701 insertions(+) create mode 100644 README.md create mode 100644 docs/PLANNING.md create mode 100644 docs/idea-meeting-2026-06-29.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..993c223 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# hikikomori + +카카오톡 대안 메신저 아이디어 — "나를 대신해 남과 대화하는 AI 분신"이 있는 메신저 프로젝트. + +- 원본 회의 자료: [`docs/idea-meeting-2026-06-29.html`](./docs/idea-meeting-2026-06-29.html) +- 기획 진행 방법: [`docs/PLANNING.md`](./docs/PLANNING.md) + +현재 단계: 아이디어 발산 및 컨셉 심화 완료. 다음 단계는 `docs/PLANNING.md`의 프로세스를 따라 +컨셉 확정 → MVP 시나리오 선정 → PRD 작성 순으로 진행합니다. diff --git a/docs/PLANNING.md b/docs/PLANNING.md new file mode 100644 index 0000000..4ae8301 --- /dev/null +++ b/docs/PLANNING.md @@ -0,0 +1,113 @@ +# 프로젝트 기획 접근 방법 — AI 분신 메신저 + +원본 회의 자료: [`idea-meeting-2026-06-29.html`](./idea-meeting-2026-06-29.html) + +## 0. 지금 어디까지 왔나 + +회의 자료는 "발산 → 컨셉 탐색 → AI 분신으로 좁혀 설계 심화"까지 진행됐다. +즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다. +이 단계에서 실패하는 가장 흔한 패턴은 두 가지다. + +- 자율성 5단계(L0~L4) + OS 레이어 확장 + 자체 메신저를 **동시에** 설계하려는 것 (범위 과다) +- 회의 자료의 "논점 Q1~Q7"을 확정하지 않은 채 기능 명세부터 쓰는 것 (순서 역전) + +그래서 아래 프로세스는 "결정 → 좁히기 → 검증 → 명세" 순서를 강제하는 데 초점을 둔다. + +## 1. 기획 프로세스 (5단계) + +``` +1) 컨셉 확정 회의 Q1~Q7에 실제로 답한다 (아래 §2) +2) 한 문장 정의 "누구를 위한, 무엇을, 왜" — Vision Doc 1페이지 +3) MVP 시나리오 선정 4개 킬러 시나리오 중 "딱 하나"만 고른다 (§3) +4) PRD 작성 선정한 시나리오 기준으로만 기능/플로우/화면 정의 +5) 기술 PoC 제일 위험한 가정부터 코드 없이/최소코드로 검증 (§4) +``` + +1→5는 순서대로, 단계를 건너뛰지 않는다. 특히 4(PRD)를 3(MVP 시나리오 선정) 전에 쓰면 +자율성 5단계 전체를 다 설계하려는 함정에 다시 빠진다. + +## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7) + +기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. + +| # | 질문 | 결정 | 비고 | +|---|------|------|------| +| Q1 | AI 분신으로 확정? | ☐ | 다른 축(기록/관계) 대비 최종 선택 근거 | +| Q2 | 타깃: 대중 vs 회사? | ☐ | 첫 사용자층에 따라 기능 우선순위가 바뀜 | +| Q3 | 자율성 몇 단계까지 출시? | ☐ | L0~L2만 먼저 여는 것을 권장 (§5) | +| Q4 | 사칭 우려 대응 충분한가? | ☐ | 분신 뱃지·거부권 등 투명성 장치 재확인 | +| Q5 | MVP 데모 시나리오 1개는? | ☐ | 읽씹 종결 / 약속 조율 / 대화 방어 중 하나 | +| Q6 | 서비스 이름 | ☐ | Echo / Twin / 분신 / Aka / 둘이서 / Mirror | +| Q7 | 자체 앱 vs OS 레이어 시작점 | ☐ | §6 참고 — 신뢰 비용 낮은 쪽부터 | + +## 3. MVP 시나리오 좁히기 + +회의 자료의 "킬러 시나리오" 4개를 검증 난이도·데모 임팩트 기준으로 비교한다. + +| 시나리오 | 필요 자율성 | 데모 임팩트 | 기술 난이도 | 비고 | +|---|---|---|---|---| +| 🌙 읽씹 종결 | L0~L2 | 높음 (전 국민 공감) | 낮음 (컨텍스트 응답 1건) | **1순위 추천** — 가장 좁고 명확 | +| 📚 단톡 따라잡기 | L0 | 중간 | 낮음 (요약만) | 발송 권한 불필요, 안전 | +| 🚫 비호감 대화 방어 | L2 | 중간 | 중간 (의도 판별 필요) | 오탐 리스크 존재 | +| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (분신 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 | + +**권장:** "읽씹 종결"과 "단톡 따라잡기"는 둘 다 L0~L2로 커버되고 같은 파이프라인(수신 메시지 요약 → +맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(분신 협상)는 +로드맵의 다음 단계로 명시적으로 미룬다. + +## 4. 기술 검증(PoC) 우선순위 + +기능을 만들기 전에, 이 아이디어를 무너뜨릴 수 있는 가정부터 코드로 확인한다. + +1. **온디바이스 말투 학습이 실제로 "나답게" 느껴지는가** — 소규모 대화 샘플로 톤 재현 정도를 사람이 직접 평가 +2. **안드로이드 알림 접근 권한(Notification Access)으로 읽기가 안정적인가** — 카톡 알림 파싱이 + OS/앱 버전에 따라 얼마나 자주 깨지는지 확인 (OS 레이어 트랙을 갈 경우 필수) +3. **자동응대가 "이상하다"고 느껴지는 임계점은 어디인가** — 실제 대화 로그로 사용자 테스트, 뱃지·거부권 + 같은 투명성 장치가 실제로 신뢰를 회복시키는지 확인 +4. **분신 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로 + 설계 문서 수준에서만 먼저 검증 (턴 수 제한, 타임아웃, 결렬 시 에스컬레이션 규칙) + +## 5. 범위 관리 원칙 — "켜는 만큼만 만든다" + +회의 자료의 자율성 사다리(L0~L4)는 제품 로드맵과 그대로 대응시킨다. + +- **MVP**: L0(비서 모드) + L1(제안 모드) + L2(부분 위임, 좁은 화이트리스트 주제만) +- **v2**: L3(자리비움 응대) — 사용자 신뢰가 쌓인 뒤 +- **v3+**: L4(분신 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤 + +절대 안전선(금전·약속 확정·민감 내용은 항상 사람에게 에스컬레이션)은 L0부터 L4까지 전 단계에서 +예외 없이 지킨다 — 이건 범위를 줄여도 타협하지 않는 항목이다. + +## 6. 자체 앱 vs OS 레이어 — 어디서 시작할까 (Q7) + +두 트랙은 경쟁 관계가 아니라 순서 문제다. + +- **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의 + API/자동입력 제약을 받아 범위가 좁다. 안드로이드 우선, 문자/이메일처럼 안정적 API가 있는 채널부터. +- **자체 메신저**: 완전한 기능(L4 분신 협상 등)이 가능한 최종 목적지. 대신 "메신저를 옮겨야 한다"는 + 채택 장벽이 크다. + +**권장 순서:** OS 레이어의 "읽기 전용 허브"(로드맵 1단계, 발송 권한 없음)로 가장 낮은 신뢰 비용부터 +검증 → 반응이 확인되면 초안 제안(2단계) → 자체 메신저는 그 다음 자연스러운 업그레이드 경로로 둔다. +두 트랙을 동시에 만들지 않는다. + +## 7. 표준 산출물 세트 (다음에 작성할 문서) + +이 문서(PLANNING.md)는 "어떻게 진행할지"에 대한 가이드다. §2의 Q1~Q7이 확정되면 아래 문서를 +순서대로 만든다. 지금 시점에 전부 미리 만들지 않는다 — 앞 단계가 안 끝나면 다음 문서는 다시 쓰게 된다. + +1. **Vision Doc (1페이지)** — 문제, 타깃, 가치제안 한 문장, 성공 지표 +2. **PRD** — MVP 시나리오 기준 기능 명세, 유저 플로우, 엣지 케이스, 화면 정의 +3. **기술 설계서** — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 +4. **리스크 로그** — 회의 자료 §2-6, §oslayer §4의 리스크를 실제 완화 상태와 함께 추적 +5. **로드맵 & 마일스톤** — §5의 L0~L4 단계, §6의 OS 레이어→자체 앱 전환 시점 반영 +6. **결정 로그** — 회의에서 확정된 것과 보류된 것을 계속 누적 기록 (이 프로젝트 자체 아이디어이기도 함) + +## 8. 다음 액션 체크리스트 + +- [ ] §2의 Q1~Q7을 회의에서 실제로 확정 (또는 보류 사유 기록) +- [ ] Vision Doc 1페이지 작성 +- [ ] MVP 시나리오 1~2개로 확정 (§3 권장안 검토) +- [ ] §4의 기술 PoC 중 1번(말투 학습)과 2번(알림 접근 안정성) 먼저 진행 +- [ ] 클릭 가능한 프로토타입(Figma 등) 제작, 뱃지·거부권 UX 포함 +- [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 diff --git a/docs/idea-meeting-2026-06-29.html b/docs/idea-meeting-2026-06-29.html new file mode 100644 index 0000000..79b013c --- /dev/null +++ b/docs/idea-meeting-2026-06-29.html @@ -0,0 +1,579 @@ + + + + + +msn 메신저 아이디어 · 회의 자료 + + + + + +
+
+
msn. 메신저 회의 자료
+ +
+
+ + +
+
+ Idea Meeting · 2026.06.29 +

카카오톡 대안 메신저 기획

+

브레인스토밍한 전체 아이디어와, 그중 가장 특색 있는 AI 분신 메신저 설계를 한 곳에 모았습니다. 회의 진행용 자료입니다.

+
🎯 최종 목표 — 대중·회사까지 아우르는 대표 메신저  |  핵심 축 — 메시지가 곧 행동이 되는 / 나를 대신하는 AI
+ +
+
1. 넓게 발산5개 카테고리로 아이디어 브레인스토밍
+
+
2. 특색 탐색"한 문장으로 정의되는" 컨셉 4종 비교
+
+
3. 선택 & 고도화AI 분신 메신저로 좁혀 설계 심화
+
+
+
+ + +
+
+
1

아이디어 보드

+

카톡에 없거나 카톡이 못 하는 것들. 발산 단계에서 모은 전체 후보입니다.

+ +
+
+
A · 대화가 기록이 된다
+

핵심 차별 축

+
    +
  • 메시지 → 할일/결정/일정 승격말풍선을 꾹 누르면 업무 데이터로 변환
  • +
  • 결정 로그 자동 누적"그때 뭐로 정했지?"가 방마다 타임라인으로
  • +
  • 번복 추적결정이 바뀌면 "이전: A" 히스토리가 따라붙음
  • +
  • 약속 자동 캘린더화"금요일 3시 ㄱㄱ"에서 일정 후보 추출
  • +
+
+
+
B · 읽씹·눈치 스트레스
+

관계 피로 줄이기

+
    +
  • 읽음 표시를 응답으로"1" 대신 확인 / 할게요 / 나중에 버튼
  • +
  • 나만의 답장 마감 알림"이따 답장" 누르면 본인에게만 리마인드
  • +
  • 나중에 모아보기안 급한 방은 정해진 시간에 묶어 알림
  • +
  • 상태 자동화회의 중·이동 중을 캘린더·위치로 자동
  • +
+
+
+
C · 프라이버시·관계
+

부담 없는 연결

+
    +
  • 전화번호 없는 메신저초대코드·링크로만 연결, 번호 노출 0
  • +
  • 관계별 분리 ID회사용 나 / 친구용 나를 한 앱에서 분리
  • +
  • 종료 시한 대화방프로젝트 끝나면 자동 보관·삭제
  • +
  • 사라지는 민감 메시지비번·주소 등 읽고 나면 소멸
  • +
+
+
+
D · AI 활용
+

똑똑한 비서

+
    +
  • 안 본 동안 요약"자리 비운 사이 뭐 정해짐?" 3줄
  • +
  • 번역 없는 대화한국어로 치면 상대는 영어로 수신
  • +
  • 톤 코치공격적인 메시지 보내기 전 한 번 확인
  • +
  • 자료 찾아주기"지난주 그 PDF 어딨지?" 자연어 검색
  • +
+
+
+
E · 카톡이 못 박는 한 방
+

기능 내장

+
    +
  • 자료가 안 사라짐파일·링크·사진을 방별 자료함에 자동 정리
  • +
  • 투표·정산 기본 내장장소·시간 투표, n빵 정산을 앱 안에서
  • +
  • 가벼운 스레드한 방에서 주제 섞여 난장판 되는 것 방지
  • +
  • 우리만의 검색특정 친구와 나눈 것만 따로 검색·앨범화
  • +
+
+
+ +
+

"오 이건 카톡에 없네" 가 터지는 한 방 후보

+

회사든 대중이든 모두가 공감하는 페인을 정확히 때리는 셋.

+
+
01
번복 추적 결정 로그
회의 뒤집기 짜증을 정확히 때림
+
02
읽씹을 응답 버튼으로
전 국민이 겪는 눈치 스트레스 해소
+
03
나중에 모아보기 알림
알림 폭격 피로를 근본적으로 해결
+
+
+ +
💡 회의 결론(현재): 위 아이디어들은 "기능 묶음"이라 정체성이 약하다는 판단 → 한 문장으로 정의되는 특색이 필요. 그래서 "나 대신 말하는 AI 분신이 있는 메신저"로 좁혀 아래에서 설계를 고도화함.
+
+
+ + +
+
+
2

AI 분신 메신저 · 설계 고도화

+

정체성 한 줄 — 챗GPT가 나와 대화하는 AI라면, 분신은 나를 대신해 남과 대화하는 나다.

+ + +

연결 구조 ① — 메시지는 이렇게 흐른다

화살표 방향으로 흐름이 움직입니다
+
+ + + + + + + + + + + + ① 메시지 도착 + ④ 나다운 응답 + ② 요약·에스컬레이션 + ③ 지시·승인 + + + + + + + + + 🧑상대 + 내 분신 + 🙂 + +
+ 들어오는 흐름 (상대 → 분신 → 나) + 나가는 흐름 (나 → 분신 → 상대) +
+

분신이 1차로 받아 정리하고, 민감·중요한 건만 나에게 올린다. 가벼운 건 ④에서 분신이 바로 응답.

+
+ + +

연결 구조 ② — 분신끼리 약속을 조율한다 (L4)

두 분신이 직접 협상
+
+ + + + + + + + + + + + + 📅 서로의 캘린더를 보고 시간 조율 + + + + + 🙂 + 내 분신 + 상대 분신 + 🧑상대 + +

두 분신이 협상한 뒤 양쪽 본인에게 후보 시간만 제안 → 최종 확정은 사람이. (둘 다 앱이 있어야 작동 = 초대 동기)

+
+ + +
1

분신 자율성 5단계

통제권은 항상 사용자에게 — 켜는 만큼만 일한다
+
+
L0
비서 모드
나에게만 요약·할 일 정리·초안 제안. 발송은 안 함
상대 모름
+
L1
제안 모드
답장 초안 생성 → 내가 한 탭으로 검토·발송
상대 모름
+
L2
부분 위임
허락한 주제만 자동 응대 (약속 조율·택배 문의·간단 안내)
상대 표시
+
L3
자리비움 응대
자는 중·운전 중 가벼운 대화를 맥락에 맞게 대신
상대 표시
+
L4
분신 협상
상대 분신과 직접 조율 (캘린더 보고 약속 시간 잡기 등)
양쪽 제안
+
+
🛡️
절대 안전선: 금전, 약속 확정, 감정적·민감한 내용은 어떤 단계에서도 자동 처리하지 않고 무조건 본인에게 에스컬레이션. 모든 위임 행동은 사후 알림 + 되돌리기 가능.
+ + +
2

신뢰·정체성 설계

"사칭 아니냐"는 의심을 투명성으로 정면 돌파
+
+
+
🔎 투명성 원칙
+
    +
  • 분신 뱃지 — 분신 발화는 말풍선에 표식이 붙어 사람 발화와 시각 구분
  • +
  • 실시간 확인 — 상대는 언제든 "지금 본인? 분신?" 물어볼 수 있음
  • +
  • 확정 불가 — 분신은 금전·약속을 확정 못 하고 "본인 확인 필요"로 보류
  • +
  • 거부권 — 상대가 "분신 말고 본인이랑만" 요청하면 자동 응대 중단
  • +
+
+
+
자료 언제 줄 수 있어?
+
✦ 지우님의 분신지금 자리 비우셨고 보통 오전에 작업하세요. 내일 10시쯤 가능할 것 같아요 — 확정은 본인 확인 후 알려드릴게요!
+
ㅇㅋ 고마워
+
(지우 본인) 내일 10시 맞아요 :)
+
분신은 점선·뱃지로 구분, 확정은 본인이 마무리
+
+
+ + +
3

"나다운" 분신 만들기

분신이 어색하면 컨셉 전체가 무너진다
+
+
🎨 말투 학습
    +
  • 온디바이스 우선 — 말투·표현·이모지 습관을 기기 안에서 학습
  • +
  • 5분 온보딩 — 가입 시 기존 대화 임포트 or 질문 몇 개로 초기 세팅
  • +
  • 교정 학습 — 분신 답을 고치면 학습해 점점 나다워짐
  • +
+
🎭 관계별 페르소나
    +
  • 톤 자동 전환 — 엄마엔 다정, 상사엔 정중, 친구엔 편하게
  • +
  • 관계 메모 — 상대별 맥락(호칭·금기어·관계 온도) 기억
  • +
  • 경계 설정 — "이 사람엔 분신 응대 끄기" 같은 관계별 규칙
  • +
+
+ + +
4

분신이 빛나는 순간 (킬러 시나리오)

+
+
🌙 읽씹 종결
  • 자는 동안 온 메시지에 분신이 "지금 자는 중, 내일 답할게" 맥락 응대 → 읽씹·답장 압박 소멸
+
📅 약속 자동 조율
  • 분신끼리 서로 캘린더를 보고 가능한 시간을 좁혀 양쪽에 후보만 제안
+
🚫 비호감 대화 방어
  • 영업·번호 캐묻기·치근덕거림을 분신이 정중히 막아 감정 노동 대신
+
📚 단톡 따라잡기
  • 밀린 단톡에서 나 언급된 것만 골라 요약 + 답장 초안 제시
+
+ + +
5

기존 AI 비서와의 결정적 차이

+
+
챗GPT · 시리 등

나와 대화하는 AI

+
VS
+
분신 메신저

나를 대신해 남과 대화하는 나

+
+ + +
6

리스크 & 안전장치

컨셉을 무너뜨릴 질문에 미리 답한다
+
+
분신이 틀린 약속을 잡으면?
모든 위임 행동은 사후 알림 + 원클릭 되돌리기. 확정성 있는 건 본인 승인 전까지 "보류".
+
상대가 자동응대를 악용(스팸)?
분신이 스팸·도배 감지 시 응대 중단하고 본인에게 보고. 무한 응답하지 않음.
+
두 분신이 무한 대화 루프?
분신끼리 대화는 목적·턴 수 제한. 결론 안 나면 양쪽 본인에게 넘김.
+
프라이버시는?
말투 학습은 온디바이스/암호화. 분신 끄기 항상 가능, 상대도 분신 거부 가능.
+
+ + +
7

확산 훅 & 수익 모델

+
+
🌱 네트워크 효과
    +
  • 분신 협상(L4)은 둘 다 앱이 있어야 작동 → "네 분신이랑 약속 잡게 하자"가 초대 동기
  • +
  • 나다운 분신은 쓸수록 똑똑해져 이탈 비용 발생
  • +
+
💳 수익 모델
    +
  • 기본 분신 무료 (L0~L2)
  • +
  • 고급 구독 — 더 똑똑한 모델, 더 넓은 위임(L3·L4), 다중 페르소나
  • +
  • 기업용 — 고객 응대 분신 (B2B 확장)
  • +
+
+ + +
8

이름 후보

"또 하나의 나"라는 정체성을 담은
+
+
Echo · 나를 따라 울리는
+
Twin · 또 하나의 나
+
분신 · 직관적 한국어
+
Aka · 또 다른 이름(alias)
+
둘이서 · 나와 분신이 함께
+
Mirror · 나를 비추는
+
+
+
+ + +
+
+
💡

획기적 아이디어 · 분신을 OS 레이어로

+

"새 메신저 앱"이 아니라 지금 쓰는 모든 대화 위에 얹히는 AI 레이어로 재정의하면 어떨까. 채택 장벽 자체를 없애는 접근.

+ + ✦ 카톡 대안이 아니라 — 모든 대화의 AI 비서 + + +
+
지금 설계

새 메신저 앱을 설치하고 옮겨와야 함

+
+
OS 레이어 확장

카톡·문자·DM 위에 분신만 얹으면 끝

+
+ + +
1

구조 — 분신이 관통하는 레이어

앱을 옮기는 게 아니라, 앱 위에 얹는다
+
+
🙂 나모든 채널의 알림·초안·결정을 한곳에서 확인
+
↑ ↓
+
✨ AI 분신 레이어읽기·요약·초안·자동응대를 채널 구분 없이 동일하게 수행
+
↑ ↑ ↑ ↑ ↑
+
+
💬카카오톡
+
✉️문자(SMS)
+
📷인스타 DM
+
📧이메일
+
💼슬랙
+
+

각 앱은 그대로 두고, 분신이 알림 접근 권한으로 관통해 동일한 경험을 제공. 자체 메신저는 "완전판"으로 나중에 자연스럽게 유도.

+
+ + +
2

왜 판이 바뀌는가

+
+
🚪
채택 장벽 제거

"메신저 옮기기"라는 가장 큰 진입 장벽이 사라짐. 앱 설치 후 권한만 허용하면 바로 가치 체감.

+
📈
시장 크기 확대

"카톡 대안 메신저" 시장이 아니라 "모든 대화의 AI 비서" 시장으로 — 타깃 인구 자체가 훨씬 커짐.

+
🔗
네트워크 효과 재설계

상대가 앱이 없어도 나는 즉시 가치를 얻음(L0~L1). 분신 협상(L4)만 "둘 다 앱 있어야" 유효 — 압박감 없는 자연 확산.

+
🏆
독점적 진입점

모든 채널의 요약·결정 로그가 한곳에 모이는 "허브"가 되면, 이후 자체 메신저·B2B 확장의 자연스러운 발판이 됨.

+
+ + +
3

단계적 도입 로드맵

권한이 큰 만큼, 신뢰를 먼저 쌓고 넓힌다
+
+
1
읽기 전용 허브
알림만 모아 요약. 발송 권한 없음 — 가장 낮은 신뢰 비용으로 시작
+
2
초안 제안
답장 초안을 만들어 각 앱으로 넘겨줌. 발송은 사람이 원래 앱에서
+
3
제한적 자동응대
허락한 채널·주제에서만 분신이 직접 응답 (L2 수준 그대로 확장)
+
4
자체 앱으로 유도
분신 협상(L4) 등 완전한 기능은 자체 메신저로 넘어와야 가능 — 자연스러운 업그레이드 동기
+
+ + +
4

기술·정책 도전 과제

이 아이디어의 진짜 리스크는 여기에 있다
+
+
각 앱이 접근을 막으면?
카톡·인스타는 자동화·크롤링을 정책상 막는 경우가 많음. 알림 접근 권한(안드로이드 Notification Access) 기반으로 시작 — 읽기는 가능하나 발송은 각 앱 자체 API/자동입력에 의존해 범위가 제한적일 수 있음.
+
OS·플랫폼 정책 리스크는?
iOS는 안드로이드보다 훨씬 제한적. 안드로이드 우선 출시로 검증 후, iOS는 자체 메신저 전환을 유도하는 전략이 현실적.
+
권한 요청이 과도해 보이지 않을까?
"모든 메시지를 읽는 앱"은 프라이버시 우려를 키움. 온디바이스 처리 원칙을 이 레이어에도 그대로 적용하고, 어떤 데이터가 어디로 가는지 실시간 대시보드로 공개.
+
각 앱 UI가 바뀌면 깨지지 않을까?
앱 업데이트마다 파싱 로직이 깨질 수 있음. 초기엔 안정적인 API가 있는 채널(문자·이메일) 우선으로 검증하고, 카톡·인스타 등은 이후 확장.
+
+ +
💡 정리: 이 아이디어는 "새 메신저를 만든다"에서 "지금 쓰는 대화 위에 AI 자아를 얹는다"로 제품 정의 자체를 바꾼다. 자체 메신저(현재 설계)는 완전한 분신 경험(L4 협상 등)을 위한 최종 목적지로 남기고, OS 레이어는 그곳으로 가는 저마찰 진입점 역할을 하는 투트랙 전략이 가능.
+
+
+ + +
+
+
3

회의 논점

+

이 자료를 띄워놓고 함께 결정하면 좋을 질문들입니다.

+
+
Q1 · 방향
AI 분신으로 확정할까?
아이디어 보드의 다른 축(기록/관계)과 비교해 최종 컨셉을 정한다.
+
Q2 · 타깃
대중 vs 회사, 어디부터?
첫 사용자층을 정해야 기능 우선순위가 잡힌다.
+
Q3 · 수용성
사람들이 AI 대리 응답을 받아들일까?
자율성 어느 단계(L0~L4)까지 현실적으로 켤지 토론.
+
Q4 · 신뢰
사칭 우려를 충분히 막았나?
투명성 장치가 사용자를 납득시키는지 점검.
+
Q5 · MVP
데모로 보여줄 한 시나리오는?
읽씹 종결 / 약속 조율 / 대화 방어 중 하나를 고른다.
+
Q6 · 이름
서비스 이름을 뭐로?
정체성을 가장 잘 담는 후보를 추린다.
+
Q7 · 전략
자체 앱 vs OS 레이어, 어디서 시작?
채택 장벽을 낮추는 OS 레이어 선출시 vs 완전한 경험의 자체 메신저 선출시, 투트랙 순서를 정한다.
+
+
+
+ + + + + From 97d70028e3e83d362c025d21c73299bc6b0e2db8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 06:25:11 +0000 Subject: [PATCH 02/33] Flesh out full planning doc set: vision, PRD, tech design, risks, roadmap Resolves the open Q1-Q7 questions as tentative decisions (self-app beta first, target consumers, MVP scenario = read-receipt relief + group-chat catch-up, autonomy capped at L0-L2) and builds the standard deliverable set on top of them, ready for review at the next meeting. --- README.md | 15 +++++++-- docs/PLANNING.md | 42 ++++++++++++----------- docs/PRD.md | 79 ++++++++++++++++++++++++++++++++++++++++++++ docs/decision-log.md | 41 +++++++++++++++++++++++ docs/risk-log.md | 21 ++++++++++++ docs/roadmap.md | 38 +++++++++++++++++++++ docs/tech-design.md | 75 +++++++++++++++++++++++++++++++++++++++++ docs/vision.md | 41 +++++++++++++++++++++++ 8 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 docs/PRD.md create mode 100644 docs/decision-log.md create mode 100644 docs/risk-log.md create mode 100644 docs/roadmap.md create mode 100644 docs/tech-design.md create mode 100644 docs/vision.md diff --git a/README.md b/README.md index 993c223..7b45bda 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,19 @@ 카카오톡 대안 메신저 아이디어 — "나를 대신해 남과 대화하는 AI 분신"이 있는 메신저 프로젝트. +## 문서 + - 원본 회의 자료: [`docs/idea-meeting-2026-06-29.html`](./docs/idea-meeting-2026-06-29.html) - 기획 진행 방법: [`docs/PLANNING.md`](./docs/PLANNING.md) +- 결정 로그 (Q1~Q7): [`docs/decision-log.md`](./docs/decision-log.md) +- Vision Doc: [`docs/vision.md`](./docs/vision.md) +- PRD (v1 클로즈드 베타): [`docs/PRD.md`](./docs/PRD.md) +- 기술 설계서: [`docs/tech-design.md`](./docs/tech-design.md) +- 리스크 로그: [`docs/risk-log.md`](./docs/risk-log.md) +- 로드맵: [`docs/roadmap.md`](./docs/roadmap.md) -현재 단계: 아이디어 발산 및 컨셉 심화 완료. 다음 단계는 `docs/PLANNING.md`의 프로세스를 따라 -컨셉 확정 → MVP 시나리오 선정 → PRD 작성 순으로 진행합니다. +## 현재 단계 + +아이디어 발산 → 컨셉 심화 → 기획 문서 세트(Vision/PRD/기술설계/리스크/로드맵) 초안 작성까지 완료. +`docs/decision-log.md`의 결정은 아직 **잠정안**이므로, 실제 회의에서 확정/수정이 필요합니다. +다음 단계는 `docs/PLANNING.md` §8 체크리스트의 남은 항목(기술 PoC, 프로토타입, 유저 인터뷰)입니다. diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 4ae8301..6f2fe59 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -83,31 +83,35 @@ 두 트랙은 경쟁 관계가 아니라 순서 문제다. - **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의 - API/자동입력 제약을 받아 범위가 좁다. 안드로이드 우선, 문자/이메일처럼 안정적 API가 있는 채널부터. -- **자체 메신저**: 완전한 기능(L4 분신 협상 등)이 가능한 최종 목적지. 대신 "메신저를 옮겨야 한다"는 - 채택 장벽이 크다. + API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(분신의 자연스러움·신뢰)과 제3자 + 플랫폼 제약(카카오톡 등의 자동화 정책)이 동시에 섞여 실패 원인을 구분하기 어렵다. +- **자체 메신저(클로즈드 베타)**: 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해 + 통제된 소규모 그룹으로 핵심 가설만 순수하게 검증할 수 있다. "메신저를 옮겨야 한다"는 채택 + 장벽은 있지만, 베타 단계에서는 어차피 소규모 초대 기반이라 큰 문제가 아니다. -**권장 순서:** OS 레이어의 "읽기 전용 허브"(로드맵 1단계, 발송 권한 없음)로 가장 낮은 신뢰 비용부터 -검증 → 반응이 확인되면 초안 제안(2단계) → 자체 메신저는 그 다음 자연스러운 업그레이드 경로로 둔다. -두 트랙을 동시에 만들지 않는다. +**최종 결정 (`decision-log.md` Q7 참고):** 자체 앱 클로즈드 베타를 먼저 만들어 핵심 가설을 +검증하고, 검증되면 그 로직을 재사용해 OS 레이어(읽기 전용 허브, 문자·이메일 우선)로 확장해 +채택 장벽을 낮춘다. 두 트랙을 동시에 만들지 않는다. (이 판단의 상세 근거는 `decision-log.md` +"Q7 상세 근거" 참고 — 이 섹션은 그 판단을 반영해 갱신되었다.) -## 7. 표준 산출물 세트 (다음에 작성할 문서) +## 7. 표준 산출물 세트 -이 문서(PLANNING.md)는 "어떻게 진행할지"에 대한 가이드다. §2의 Q1~Q7이 확정되면 아래 문서를 -순서대로 만든다. 지금 시점에 전부 미리 만들지 않는다 — 앞 단계가 안 끝나면 다음 문서는 다시 쓰게 된다. +§2의 Q1~Q7에 잠정 결정을 내리고 아래 문서를 작성했다. 결정이 뒤집히면 이 문서들도 함께 갱신한다. -1. **Vision Doc (1페이지)** — 문제, 타깃, 가치제안 한 문장, 성공 지표 -2. **PRD** — MVP 시나리오 기준 기능 명세, 유저 플로우, 엣지 케이스, 화면 정의 -3. **기술 설계서** — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 -4. **리스크 로그** — 회의 자료 §2-6, §oslayer §4의 리스크를 실제 완화 상태와 함께 추적 -5. **로드맵 & 마일스톤** — §5의 L0~L4 단계, §6의 OS 레이어→자체 앱 전환 시점 반영 -6. **결정 로그** — 회의에서 확정된 것과 보류된 것을 계속 누적 기록 (이 프로젝트 자체 아이디어이기도 함) +1. [`vision.md`](./vision.md) — 문제, 타깃, 가치제안 한 문장, 성공 지표 +2. [`PRD.md`](./PRD.md) — MVP 시나리오 기준 기능 명세, 유저 플로우, 엣지 케이스 +3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 +4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적 +5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, OS 레이어→자체 앱 전환 시점 반영 +6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록 ## 8. 다음 액션 체크리스트 -- [ ] §2의 Q1~Q7을 회의에서 실제로 확정 (또는 보류 사유 기록) -- [ ] Vision Doc 1페이지 작성 -- [ ] MVP 시나리오 1~2개로 확정 (§3 권장안 검토) -- [ ] §4의 기술 PoC 중 1번(말투 학습)과 2번(알림 접근 안정성) 먼저 진행 +- [x] §2의 Q1~Q7 잠정 결정 — `decision-log.md` (실제 회의에서 재확인/뒤집기 필요) +- [x] Vision Doc 작성 — `vision.md` +- [x] MVP 시나리오 확정 (읽씹 종결 + 단톡 따라잡기) — `PRD.md` +- [x] PRD, 기술 설계서, 리스크 로그, 로드맵 작성 +- [ ] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 먼저 진행 — 아직 코드/실사용 검증 없음 - [ ] 클릭 가능한 프로토타입(Figma 등) 제작, 뱃지·거부권 UX 포함 - [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 +- [ ] 실제 회의에서 이 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..8c46164 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,79 @@ +# PRD — 분신 v1 (클로즈드 베타) + +범위: `vision.md`의 가치 제안을 자체 앱 클로즈드 베타로 구현한다. 자율성 L0~L2, 시나리오는 +"읽씹 종결"과 "단톡 따라잡기" 두 가지로 한정한다. (근거: `decision-log.md` Q3, Q5) + +## 1. 페르소나 + +- **주 사용자(분신을 쓰는 나)** — 메신저 응답 압박을 자주 느끼는 개인. 분신을 켜고, 응답을 + 검토·승인하거나 위임 범위를 조정한다. +- **상대방** — 분신 사용자와 대화하는 사람. 앱을 설치하지 않았어도 분신의 응답을 받을 수 있어야 + 하며(v1은 자체 앱 내부이므로 상대방도 사실상 앱 사용자), 분신 여부를 언제든 확인·거부할 수 있다. + +## 2. 유저 플로우 + +### 2.1 온보딩 (말투 학습) +1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내) +2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원 +3. 분신 자율성 기본값은 **L0(비서 모드)** — 사용자가 명시적으로 올려야 L1, L2로 이동 + +### 2.2 읽씹 종결 시나리오 (핵심) +1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안 +2. 상대가 메시지를 보냄 → 분신이 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성 +3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값) +4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해 + 즉시 자동 발송, 사후 알림 +5. 상대에게는 분신 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고) +6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성 + +### 2.3 단톡 따라잡기 시나리오 +1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청 +2. 분신이 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약 +3. 답장이 필요한 항목에는 초안 버튼 제공 (발송은 항상 사용자 승인 — 이 시나리오는 L0 고정, + 자동 발송 없음) + +## 3. 기능 명세 + +### 3.1 P0 (v1 필수) + +| 기능 | 설명 | +|---|---| +| 분신 뱃지 | 분신이 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) | +| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 | +| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 | +| 화이트리스트 주제 | L2에서 자동 발송을 허용할 주제/상대를 사용자가 직접 등록 | +| 에스컬레이션 규칙 | 금전, 약속 확정, 감정적/민감 내용 감지 시 자동 처리 금지, 무조건 사용자에게 넘김 | +| 실시간 본인 확인 | 상대가 "지금 본인이야 분신이야?" 물으면 분신이 스스로 정직하게 답함 | +| 거부권 | 상대가 "분신 말고 본인이랑만" 요청 시 해당 대화에서 자동응대 즉시 중단 | +| 사후 알림 + 되돌리기 | 분신이 취한 모든 자동 행동은 알림 로그에 남고 원클릭 취소 가능 | +| 단톡 요약(멘션 기준) | 안 본 동안 나 언급/결정 사항만 골라 요약 | +| 관계별 페르소나(최소 2종) | 가까운 사이 / 공식적인 사이 톤 구분 | + +### 3.2 P1 (v1 이후 곧) + +- 답장 마감 알림(내가 "이따 답장" 누르면 나에게만 리마인드) +- 스팸/도배 감지 시 응대 자동 중단 +- 관계 메모(호칭, 금기어 기억) + +### 3.3 P2 / 명시적 범위 밖 (v2+) + +- L3 자리비움 전면 응대, L4 분신 협상 — `roadmap.md` 참고 +- OS 레이어(타 앱 알림 관통) — `roadmap.md` 참고 +- 다중 페르소나, 구독 결제 — 수익 모델은 베타 이후 + +## 4. 엣지 케이스 + +| 상황 | 처리 | +|---|---| +| 분신이 잘못된 맥락으로 응답 초안을 만듦 | L1은 발송 전 사용자 검토이므로 그 자리에서 수정. L2는 사후 알림 + 되돌리기 | +| 상대가 분신에게 확정을 요구(예: "그럼 3시 확정이지?") | 분신은 확정 불가 — "본인 확인 필요"로 응답하고 사용자에게 에스컬레이션 | +| 상대가 짧은 시간에 메시지 도배 | 스팸 감지 임계치 초과 시 응대 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요) | +| 사용자가 여러 상대에게 다른 자율성 레벨을 원함 | 상대별 예외 설정으로 지원 (전역 기본값 + 오버라이드) | +| 온보딩 시 임포트할 기존 대화가 없음 | 질문 기반 초기 세팅 경로로 폴백 | + +## 5. 성공 지표 + +`vision.md`의 v1 성공 지표를 그대로 따른다. 이 PRD 범위에서 추가로 추적할 것: + +- L1 초안 승인율(수정 없이 그대로 발송된 비율) — 말투 학습 품질의 대리 지표 +- L2 화이트리스트 자동발송 후 되돌리기 사용률 — 낮을수록 신뢰 신호 diff --git a/docs/decision-log.md b/docs/decision-log.md new file mode 100644 index 0000000..f50ce29 --- /dev/null +++ b/docs/decision-log.md @@ -0,0 +1,41 @@ +# 결정 로그 + +회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. +상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를 +쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 +하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 +그대로 실천하는 것이기도 하다.) + +| # | 질문 | 결정 | 근거 | 상태 | +|---|------|------|------|------| +| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | +| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | +| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | +| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | +| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | +| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | + +## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 + +`PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 +제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. + +- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 + 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, + 신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. +- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 + 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. +- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — + 버리는 작업이 아니다. + +**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 +OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 +L3·L4 등 완전한 기능의 최종 목적지로 유지. + +## 아직 열려 있는 하위 질문 + +- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 + 실사용 데이터로 계속 조정 필요 +- 상표/네이밍 최종안 (Q6은 가칭) +- 베타 참가자 모집 규모와 방식 (§로드맵 참고) diff --git a/docs/risk-log.md b/docs/risk-log.md new file mode 100644 index 0000000..d8c7c37 --- /dev/null +++ b/docs/risk-log.md @@ -0,0 +1,21 @@ +# 리스크 로그 + +회의 자료(§2-6, §oslayer §4)와 `PRD.md`/`tech-design.md` 작성 중 식별된 리스크를 한곳에서 +추적한다. 완화 방안이 "설계상 반영됨"인 항목도 실사용 검증 전까지는 가설이다. + +| 리스크 | 영향 | 완화 방안 | 상태 | +|---|---|---|---| +| 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 | +| 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 | +| 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (P1, v1 최소 버전 필요) | 부분 반영 — v1 최소 버전 필요, `PRD.md` §4 | +| 에스컬레이션 판정기 오탐/누락 | 안전선 위반(금전/약속 자동 확정) 가능성 | 규칙 기반 + 애매하면 항상 에스컬레이션(fail-safe) | 설계 반영 — 오탐/누락률은 베타 로그로 계속 튜닝 | +| 온디바이스 말투 학습 품질 미흡 | "나답지 않다"는 인상, 핵심 가치 제안 실패 | PoC #1(§4)로 사전 검증, 교정 학습으로 지속 개선 | 미검증 — 최우선 PoC | +| 배터리/성능 부담 (온디바이스 추론) | 사용자 이탈 | 경량 모델 우선, 부족 시 서버 폴백 | 미검증 | +| 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 | +| (v2 대비) 카카오톡/인스타 알림 파싱이 앱 업데이트로 깨짐 | OS 레이어 확장 시 유지보수 부담 | v1 범위에서 제외, v2 착수 시 안정적 API 채널(문자·이메일) 우선 | v1 범위 밖 — 그대로 유지 | +| (v2 대비) iOS 플랫폼 정책 제약 | OS 레이어 iOS 확장 어려움 | 안드로이드 우선 검증 후 iOS는 자체 앱 전환 유도 | v1 범위 밖 — 그대로 유지 | + +## 우선순위 + +지금 시점에서 가장 먼저 확인해야 할 두 가지는 **온디바이스 말투 학습 품질**과 **사칭/신뢰 수용성**이다. +이 둘이 무너지면 나머지 리스크 완화는 의미가 없다. `PLANNING.md` §4의 PoC #1, #3을 최우선으로 진행한다. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..2e76ecf --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,38 @@ +# 로드맵 & 마일스톤 + +`decision-log.md`의 Q3(자율성 단계), Q7(자체앱→OS레이어 순서)에 따른 단계별 계획. +각 단계는 이전 단계의 핵심 가설이 검증되어야 다음으로 넘어간다 — 일정보다 검증 결과가 게이트다. + +## Phase 0 — 기술 PoC (착수 즉시) + +- 온디바이스 말투 학습 품질 검증 (`PLANNING.md` §4 PoC #1) +- 에스컬레이션 판정기(규칙 기반) 최소 프로토타입으로 오탐/누락 감 잡기 +- 목표: "분신이 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보 + +## Phase 1 — v1 클로즈드 베타 (자체 앱, `PRD.md` 범위) + +- 자율성 L0~L2, 시나리오: 읽씹 종결 + 단톡 따라잡기 +- 안드로이드 우선, 초대 기반 소규모 베타 (지인 네트워크) +- 게이트: `vision.md` 성공 지표(자연스러움 70%, 거부율 10% 미만, 안전선 위반 0건) 충족 여부 + +## Phase 2 — L3 확장 + 베타 확대 + +- 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만 +- 관계 메모, 답장 마감 알림 등 P1 기능 +- 베타 규모를 소규모 지인 네트워크 밖으로 확대 + +## Phase 3 — OS 레이어 진입 (성장 전략) + +- 읽기 전용 허브부터 (발송 권한 없음, 문자·이메일 등 안정적 API 채널 우선) +- 순서: 읽기 전용 → 초안 제안 → 제한적 자동응대(L2 그대로 확장) → 자체 앱으로 유도 +- 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응 + +## Phase 4 — L4 분신 협상 + B2B 확장 + +- 사용자 기반이 어느 정도 쌓여 네트워크 효과가 의미 있을 때 착수 +- 기업용 고객 응대 분신(B2B)은 이 시점 이후 별도 트랙으로 검토 + +## 명시적으로 지금 계획하지 않는 것 + +- Phase 1 게이트를 통과하기 전에 Phase 2 이후 기능을 설계/개발하지 않는다. +- OS 레이어와 자체 앱을 동시에 만들지 않는다 (`decision-log.md` Q7 근거). diff --git a/docs/tech-design.md b/docs/tech-design.md new file mode 100644 index 0000000..c73b4cb --- /dev/null +++ b/docs/tech-design.md @@ -0,0 +1,75 @@ +# 기술 설계서 — 분신 v1 + +`PRD.md` 범위(자체 앱, L0~L2, 읽씹 종결 + 단톡 따라잡기)를 구현하기 위한 아키텍처 개요. +상세 API 스펙이 아니라 경계와 원칙을 정의하는 문서다 — 실제 구현 시 세부 설계는 별도로 좁혀나간다. + +## 1. 전체 구조 + +``` +[클라이언트 앱 (Android 우선, iOS 병행 검토)] + ├─ 메시지 UI / 뱃지 렌더링 + ├─ 온디바이스 말투 모델 (경량, 로컬 추론) + ├─ 자율성 엔진 (L0~L2 규칙 + 에스컬레이션 판정) + └─ 로컬 이벤트 로그 (사후 알림 / 되돌리기용) + │ (암호화된 동기화만, 원문은 최소 전송) + ▼ +[서버] + ├─ 메시지 릴레이 / 대화방 저장 + ├─ 응답 초안 생성 (온디바이스로 부족한 경우의 폴백 LLM 호출) + └─ 화이트리스트·설정 동기화 (기기 간) +``` + +## 2. 온디바이스 vs 서버 경계 + +- **말투 학습(온보딩, 교정 학습)은 온디바이스 우선.** 원문 대화를 서버로 올리지 않고 + 기기 내에서 스타일 특징만 추출·저장한다. (`vision.md`의 신뢰 가설과 직결 — 여기서 타협하면 + Q4 사칭/프라이버시 우려가 그대로 리스크로 남는다) +- **응답 초안 생성은 온디바이스 우선 + 서버 폴백.** 기기 성능/배터리 제약으로 온디바이스 모델이 + 처리 못 하는 경우에만 서버 LLM 호출, 이 경우도 필요한 최소 컨텍스트만 전송. +- **자율성 엔진(L0~L2 판정, 에스컬레이션 규칙)은 클라이언트에 둔다.** 금전/약속/민감 감지가 + 서버 왕복 지연이나 서버 장애에 영향받지 않아야 안전선이 항상 지켜진다. +- **대화방 저장/릴레이는 서버.** 멀티 디바이스 동기화, 상대방에게 메시지를 전달하는 기본 기능. + +## 3. 자율성 엔진 (L0~L2) + +1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류) +2. 에스컬레이션 대상이면 무조건 사용자에게 알림만 하고 종료 (자동 처리 안 함) — **이 경로엔 예외 없음** +3. 아니면 자율성 레벨 확인: + - L0: 초안만 생성해 사용자에게 보여줌, 발송 없음 + - L1: 초안 생성 + 발송 승인 요청 알림 + - L2: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등 +4. 발송된 모든 자동 응답은 로컬 이벤트 로그에 기록 (사후 알림 + 되돌리기 버튼 노출) + +에스컬레이션 판정기는 v1에서는 규칙 기반(키워드 + 간단한 의도 분류) + 온디바이스 모델의 결합으로 +시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면 +항상 에스컬레이션 쪽으로 fail-safe. + +## 4. 투명성 구현 + +- **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용 + (서버가 신뢰의 원천 — 클라이언트 임의 조작 방지를 위해 서명 포함) +- **실시간 본인 확인**: "본인/분신" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리, + 분신은 항상 정직하게 "저는 분신입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정) +- **거부권**: 대화방 단위 플래그(`twin_disabled_by_peer`) — 상대방의 거부 요청이 감지되면 즉시 + 해당 대화방에서 L1/L2 자동 동작을 끄고 L0으로 강등 + +## 5. 데이터/프라이버시 원칙 + +- 원문 대화 내용은 기본적으로 기기 내 저장, 서버는 릴레이에 필요한 최소 기간만 보관 +- 말투 특징 벡터 등 학습 산출물은 암호화 저장, 사용자가 언제든 초기화 가능 +- 어떤 데이터가 어디로 가는지 설정 화면에서 실시간으로 확인 가능한 대시보드 제공 (베타 초기엔 + 최소한 "이 대화는 서버로 갔음/기기에만 있음" 표시 수준으로 시작) + +## 6. 플랫폼 전략 + +- **Android 우선.** 알림 접근 권한 기반 확장(v2 OS 레이어)을 염두에 둔 선택이기도 하고, v1 + 자체 앱 단계에서도 베타 규모라면 단일 플랫폼으로 좁혀 개발 속도를 확보하는 게 유리하다. +- iOS는 베타 반응 확인 후 병행 착수. (근거: `decision-log.md` Q7, 원본 회의자료 §oslayer 리스크 + "iOS는 안드로이드보다 훨씬 제한적") + +## 7. v1에서 의도적으로 안 만드는 것 + +- OS 레이어(알림 파싱 기반 타 앱 관통) — v2. 지금 만들면 카카오톡 등 UI 변경에 따라 계속 깨지는 + 파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다. +- 분신 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음. +- 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충. diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..a311c1b --- /dev/null +++ b/docs/vision.md @@ -0,0 +1,41 @@ +# Vision Doc — 분신 (가칭) + +## 문제 + +메신저에서 사람들은 "읽었으면 답해야 한다"는 무언의 압박(읽씹 스트레스), 자는 동안/바쁠 때 +쌓인 메시지를 다 확인해야 하는 피로, 그리고 원치 않는 대화(영업, 번호 캐묻기)를 정중히 +받아내야 하는 감정 노동을 일상적으로 겪는다. 기존 AI 비서(챗GPT, 시리 등)는 **나와** 대화할 뿐, +**나를 대신해 남과** 대화해주지 않는다. + +## 타깃 사용자 (v1) + +일반 개인 사용자 — 특히 메신저 응답 압박을 자주 느끼는 20~30대. 회사/팀 사용 사례(B2B)는 +v1 이후 확장 대상으로 명시적으로 제외한다. (근거: `decision-log.md` Q2) + +## 가치 제안 (한 문장) + +> 분신은 내가 답할 수 없는 순간에도, 나 대신 나답게 응답해 읽씹 압박과 응답 피로를 없애주는 +> 메신저다. + +## 이번 v1에서 하지 않는 것 (Non-goals) + +- 분신 간 협상(L4, 약속 자동 조율) — 네트워크 효과가 필요한 단계라 사용자 기반이 있어야 의미가 있음 +- 자리비움 전면 자동응대(L3) — 신뢰가 쌓이기 전에는 리스크가 큼 +- 카카오톡/인스타그램 등 타 채널 위 OS 레이어 — v2 성장 전략으로 미룸 +- 기업용(B2B) 고객 응대 분신 + +## 성공 지표 (v1 클로즈드 베타 기준) + +| 지표 | 목표 | 측정 방법 | +|---|---|---| +| 분신 응답 자연스러움 | 베타 참가자의 70% 이상이 "내 말투 같다"고 평가 | 온보딩 후 설문 | +| 신뢰 유지 | 상대방의 분신 거부율 10% 미만 | 거부권 사용 로그 | +| 핵심 시나리오 도달률 | 베타 참가자 50% 이상이 "읽씹 종결" 시나리오를 1주 내 실사용 | 이벤트 로그 | +| 리텐션 | 2주차 재사용률 40% 이상 | DAU/재방문 로그 | +| 안전선 위반 0건 | 금전/약속 확정을 분신이 자동 처리한 사례 0건 | 에스컬레이션 로그 감사 | + +## 핵심 축 요약 + +- **정체성 한 줄:** 챗GPT가 나와 대화하는 AI라면, 분신은 나를 대신해 남과 대화하는 나. +- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은 + 어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다. From 3c97060606c02950bd3280181f7c9e5126385690 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 07:12:25 +0000 Subject: [PATCH 03/33] Add AGENTS.md and CLAUDE.md for Cursor/Claude project rules Introduce a single source of truth for agent guidance during the planning-docs phase: document authority, tentative Q1-Q7 handling, v1 scope guards, safety invariants, and next PoC priorities. Co-authored-by: okuma --- AGENTS.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 15 ++++++++ README.md | 5 +++ 3 files changed, 123 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f61748c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# Project Instructions — hikikomori / 분신 (가칭) + +This repository is currently a **planning-docs repo** for an AI-twin messenger +("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet. + +Canonical docs live under `docs/`. Prefer linking to them over copying content +into prompts or new files. + +## Document authority + +When documents disagree, follow this order: + +1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 +2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md) +3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md) +4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide +5. [`docs/idea-meeting-2026-06-29.html`](docs/idea-meeting-2026-06-29.html) — historical meeting source only + +Notes: + +- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting + decisions. Do not silently reverse them. If a change is required, update + `decision-log.md` and all derived docs in the same change. +- `PLANNING.md` §2 may still show empty checkboxes (`☐`). That is stale + template state — prefer `decision-log.md` for current working answers. +- Stale wording such as “OS 레이어 → 자체 앱” is wrong. The working order is + **자체 앱 클로즈드 베타 first → OS 레이어 later** (`decision-log.md` Q7). + +## Product identity (v1 working assumptions) + +- Name (working): **분신** +- One-liner: ChatGPT talks *with* me; 분신 talks *as* me *to others* +- Target: consumer individuals first (not B2B) +- Delivery: self-owned messenger, closed beta +- Autonomy for v1: **L0~L2 only** +- MVP scenarios: **읽씹 종결** + **단톡 따라잡기** +- Platform: Android first + +## Hard scope guards (do not expand without an explicit decision-log update) + +Out of v1 scope: + +- L3 full away-mode auto-reply +- L4 twin-to-twin negotiation / appointment auto-finalization +- OS-layer bridging over KakaoTalk / Instagram / etc. +- B2B / workspace products +- Server-side full conversation analytics that violate on-device-first + +Do not design or implement Phase 2+ work before Phase 1 gates are validated +(`docs/roadmap.md`). + +## Absolute safety invariants + +These apply at every autonomy level and must not be weakened for convenience: + +- Money, appointment confirmation, and emotional/sensitive topics always + escalate to the human. When uncertain, escalate (fail-safe). +- Twin-authored messages must be visually distinct (badge / `sender_mode`). +- Honest identity answers: if asked “본인이야 분신이야?”, answer as twin. +- Peer veto: if the other person rejects the twin, disable auto-reply for that + conversation immediately. +- Every automatic action needs post-hoc notification + one-tap undo. + +## Privacy & architecture principles + +- Tone/style learning: **on-device first**. Do not default to uploading raw chat + history to servers. +- Autonomy / escalation engine: **client-side** so safety is not blocked by + server latency or outage. +- Draft generation: on-device first, server LLM fallback with minimal context. +- Relay/storage server is allowed; it is not a license for full cloud analysis. + +## Process rules + +Follow `docs/PLANNING.md`: decide → narrow → validate → specify. + +Current next priorities (do not invent a full stack first): + +1. PoC #1 — on-device tone realism +2. PoC #3 — impersonation / trust acceptance (badge, veto UX) +3. Clickable prototype (badge + veto) +4. Small user interviews +5. Meeting review to promote Q1~Q7 from 제안 → 확정 + +Do not invent frameworks, folder layouts, or CI conventions until real code +exists. When code work starts, prefer Android-first changes aligned with +`docs/tech-design.md` and keep L0~L2 + safety invariants intact. + +## Documentation conventions + +- Planning docs are written in **Korean**. +- Keep decisions traceable with links to `decision-log.md` Q# and related files. +- Preserve `idea-meeting-2026-06-29.html` as historical source material; do not + treat it as the live decision record. +- When status changes, keep `PLANNING.md` §8, `roadmap.md`, and `risk-log.md` + in sync. +- Prefer updating existing docs over creating parallel overlapping docs. + +## Communication with agents + +- Read the relevant docs before proposing product/tech changes. +- Call out whether a suggestion is inside v1 scope or a future-phase idea. +- If requirements are ambiguous, ask before expanding scope. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b91be9a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,15 @@ +# Claude Code — hikikomori / 분신 + +Follow the project instructions in [@AGENTS.md](./AGENTS.md). + +Quick context: + +- This repo is planning docs only (no app code yet). +- Working product name: **분신** (tentative). +- Source of working decisions: `docs/decision-log.md` (status: 제안). +- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. +- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. +- Never weaken escalation, twin badge, peer veto, or undo. + +Before changing product or technical direction, read `AGENTS.md` and the +relevant files under `docs/`. diff --git a/README.md b/README.md index 7b45bda..d500e05 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ - 리스크 로그: [`docs/risk-log.md`](./docs/risk-log.md) - 로드맵: [`docs/roadmap.md`](./docs/roadmap.md) +## AI 에이전트 규칙 + +- Cursor / 공통: [`AGENTS.md`](./AGENTS.md) +- Claude Code 진입점: [`CLAUDE.md`](./CLAUDE.md) + ## 현재 단계 아이디어 발산 → 컨셉 심화 → 기획 문서 세트(Vision/PRD/기술설계/리스크/로드맵) 초안 작성까지 완료. From 2714fbef438f9f97a4cc29f4ebc8468a83d7a363 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 07:14:23 +0000 Subject: [PATCH 04/33] Sync planning docs with tentative Q1-Q7 decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update PLANNING.md §2 from empty checkboxes to the working answers in decision-log, fix the reverse OS/self-app wording, and clarify roadmap Phase 3 so OS-layer growth does not rewrite the start order. Co-authored-by: okuma --- AGENTS.md | 8 ++++---- docs/PLANNING.md | 20 +++++++++++--------- docs/roadmap.md | 4 +++- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f61748c..c9f4513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,10 @@ Notes: - Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting decisions. Do not silently reverse them. If a change is required, update `decision-log.md` and all derived docs in the same change. -- `PLANNING.md` §2 may still show empty checkboxes (`☐`). That is stale - template state — prefer `decision-log.md` for current working answers. -- Stale wording such as “OS 레이어 → 자체 앱” is wrong. The working order is - **자체 앱 클로즈드 베타 first → OS 레이어 later** (`decision-log.md` Q7). +- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for + current working answers. +- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** + (`decision-log.md` Q7). Do not reverse that sequence. ## Product identity (v1 working assumptions) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 6f2fe59..95e2826 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -29,16 +29,18 @@ ## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7) 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. +현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다. +회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다. -| # | 질문 | 결정 | 비고 | +| # | 질문 | 잠정 결정 | 상태 | |---|------|------|------| -| Q1 | AI 분신으로 확정? | ☐ | 다른 축(기록/관계) 대비 최종 선택 근거 | -| Q2 | 타깃: 대중 vs 회사? | ☐ | 첫 사용자층에 따라 기능 우선순위가 바뀜 | -| Q3 | 자율성 몇 단계까지 출시? | ☐ | L0~L2만 먼저 여는 것을 권장 (§5) | -| Q4 | 사칭 우려 대응 충분한가? | ☐ | 분신 뱃지·거부권 등 투명성 장치 재확인 | -| Q5 | MVP 데모 시나리오 1개는? | ☐ | 읽씹 종결 / 약속 조율 / 대화 방어 중 하나 | -| Q6 | 서비스 이름 | ☐ | Echo / Twin / 분신 / Aka / 둘이서 / Mirror | -| Q7 | 자체 앱 vs OS 레이어 시작점 | ☐ | §6 참고 — 신뢰 비용 낮은 쪽부터 | +| Q1 | AI 분신으로 확정? | 예 | 제안 — 근거는 `decision-log.md` | +| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 | +| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 | +| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 | +| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 | +| Q6 | 서비스 이름 | "분신" (가칭) | 제안 (가칭) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 | ## 3. MVP 시나리오 좁히기 @@ -102,7 +104,7 @@ 2. [`PRD.md`](./PRD.md) — MVP 시나리오 기준 기능 명세, 유저 플로우, 엣지 케이스 3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적 -5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, OS 레이어→자체 앱 전환 시점 반영 +5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영 6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록 ## 8. 다음 액션 체크리스트 diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e76ecf..0183703 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,8 +23,10 @@ ## Phase 3 — OS 레이어 진입 (성장 전략) +- 전제: Phase 1 자체 앱 베타에서 핵심 가설이 검증된 뒤에만 착수 (`decision-log.md` Q7) - 읽기 전용 허브부터 (발송 권한 없음, 문자·이메일 등 안정적 API 채널 우선) -- 순서: 읽기 전용 → 초안 제안 → 제한적 자동응대(L2 그대로 확장) → 자체 앱으로 유도 +- OS 레이어 내부 순서: 읽기 전용 → 초안 제안 → 제한적 자동응대(L2 그대로 확장) +- 확산 후 완전한 기능(L3·L4 등)이 필요하면 자체 앱으로 유도 — 시작 순서를 뒤집는 뜻이 아님 - 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응 ## Phase 4 — L4 분신 협상 + B2B 확장 From bda759010a8755c0ae933c32b9ead1784e7b8402 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:28:33 +0000 Subject: [PATCH 05/33] Add PoC #1/#3 execution plan with Go/No-Go criteria Turns the two highest-priority open risks (on-device tone realism, impersonation/trust acceptance) into runnable protocols: sample collection, blind evaluation, role-play scripts, and pass/fail thresholds, so results can update decision-log.md and risk-log.md. --- docs/PLANNING.md | 7 +++-- docs/poc-plan.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 docs/poc-plan.md diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 95e2826..2da123b 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -106,6 +106,7 @@ 4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적 5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영 6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록 +7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 ## 8. 다음 액션 체크리스트 @@ -113,7 +114,9 @@ - [x] Vision Doc 작성 — `vision.md` - [x] MVP 시나리오 확정 (읽씹 종결 + 단톡 따라잡기) — `PRD.md` - [x] PRD, 기술 설계서, 리스크 로그, 로드맵 작성 -- [ ] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 먼저 진행 — 아직 코드/실사용 검증 없음 +- [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md` + (계획만 작성됨, 실제 참가자 모집·데이터 수집·인터뷰는 아직 실행 전 — `poc-plan.md` §4 참고) +- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 - [ ] 클릭 가능한 프로토타입(Figma 등) 제작, 뱃지·거부권 UX 포함 -- [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 +- [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 (PoC #3와 통합 진행 가능) - [ ] 실제 회의에서 이 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 diff --git a/docs/poc-plan.md b/docs/poc-plan.md new file mode 100644 index 0000000..dbef85f --- /dev/null +++ b/docs/poc-plan.md @@ -0,0 +1,81 @@ +# PoC 실행 계획 — #1 온디바이스 말투 학습 · #3 사칭/신뢰 수용성 + +`risk-log.md` 우선순위에 따라 이 두 PoC가 가장 시급하다: 이게 무너지면 나머지 기획(PRD, 로드맵)은 +의미가 없다. 이 문서는 팀이 바로 실행할 수 있는 수준까지 방법론을 구체화한다. 실행 자체(실제 대화 +데이터 수집, 참가자 인터뷰)는 이 문서 작성 시점에는 아직 이루어지지 않았다 — 다음 액션은 §4 참고. + +## PoC #1 — 온디바이스 말투 학습이 "나답게" 느껴지는가 + +### 목적 +`vision.md`의 핵심 가치제안("나 대신 나답게 응답")이 성립하는지 최소 비용으로 먼저 확인한다. +이게 실패하면 자율성 단계(L0~L2)나 신뢰 장치 설계는 전부 무의미해진다. + +### 방법 +1. **샘플 수집**: 참가자 3~5명당 최근 대화 50~100개(카카오톡 내보내기 등) 확보. 민감한 대화는 + 참가자가 직접 제외하도록 안내 (연구 목적 최소 수집 원칙, `tech-design.md` §5와 동일한 원칙 적용) +2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 분신 + 응답과 비교하지 않고 감춰둔 답)으로 나눈다 +3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 분신이 응답 초안을 생성 +4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 분신 초안"을 + 섞어서 보여주고 구분 가능한지 + "내(그의) 말투 같다"를 5점 척도로 평가 + +### 평가 기준 (Go/No-Go) +| 지표 | 기준 | +|---|---| +| "말투 같다" 평균 점수 | 4/5 이상 | +| 실제 답장과 분신 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) | +| 명백히 "이상하다"는 반응 비율 | 10% 미만 | + +기준 미달 시: `vision.md`의 가치제안 자체를 재검토하거나, 온디바이스 학습 방식(특징 추출 방식, +학습 데이터량)을 먼저 개선하고 재시도. 재시도 없이 다음 단계(PRD 구현)로 넘어가지 않는다. + +### 필요한 것 +- 참가자 3~5명 (내부 팀/지인으로 충분, 이 단계는 소규모 신호 확인이 목적) +- 응답 초안 생성기 (이 세션에서는 프로토타입 수준 — 실제 온디바이스 모델이 아니어도, LLM 프롬프트에 + 말투 특징을 주입하는 방식으로 초기 신호를 얻을 수 있음. 배터리/성능 검증은 이 PoC의 범위가 아님) +- 블라인드 평가지 (설문 형태, 5점 척도 + 자유 코멘트) + +## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX) + +### 목적 +`decision-log.md` Q4("사칭 우려 대응 충분한가?")는 아직 가설이다. 뱃지·거부권·확정 불가 원칙이 +실제로 상대방의 신뢰를 지키는지, 설계 문서 밖에서 확인한다. + +### 방법 +1. **역할극 시나리오 스크립트 작성**: `PRD.md` §2.2 읽씹 종결 흐름을 그대로 재현하는 대화 스크립트 + 3~4종 (일반적인 안부, 업무 문의, 약속 잡기 시도, 감정적인 대화 — 에스컬레이션 되어야 하는 경우 포함) +2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 시나리오로 분신과 대화하게 함 (Wizard-of-Oz + 방식 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신 수행해도 됨, 이 단계에서는 UX/신뢰 + 반응만 측정) +3. **관찰 포인트**: + - 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가 + - "본인이야 분신이야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가 + - 거부권("본인이랑만 얘기하고 싶다")을 실제로 쓰고 싶어지는 순간이 있는가, 그 요청이 잘 반영됐다고 느끼는가 + - 약속/금전 얘기가 나왔을 때 분신이 보류하는 것에 대한 반응 (안심 vs 답답함) +4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 분신과 대화하고 싶은가" 개방형 질문 + +### 평가 기준 (Go/No-Go) +| 지표 | 기준 | +|---|---| +| 뱃지 인지율 | 90% 이상 (인지 못하면 투명성 설계 자체가 실패) | +| "불편했다" 응답 비율 | 20% 미만 | +| 거부권 사용 의사 있었는데 실제로 못 쓴 경우 | 0건 (UX 결함으로 간주) | + +기준 미달 시: `PRD.md` §3.1의 투명성 관련 기능(뱃지 표현, 본인확인 응답 문구, 거부권 노출 위치)을 +먼저 개선. 이 PoC는 반복 가능해야 하므로 스크립트는 재사용 가능한 형태로 `docs/poc-plan.md`에 계속 +누적한다. + +## 결과를 문서에 반영하는 방법 + +두 PoC 모두 결과가 나오면: +1. `decision-log.md`의 해당 Q(Q3, Q4)를 "제안" → "확정" 또는 "재검토 필요"로 갱신 +2. Go 판정이면 `PLANNING.md` §8 체크리스트에 완료 표시 +3. No-Go면 `risk-log.md`에 구체적 실패 모드를 새 리스크 행으로 추가하고, 재시도 계획을 이 문서에 추가 + +## 다음 액션 (이 문서 작성 다음 단계) + +- [ ] PoC #1: 참가자 3~5명 확보 + 대화 샘플 수집 동의 +- [ ] PoC #1: 응답 초안 생성 프로토타입 준비 (LLM 프롬프트 기반 초기 버전으로 충분) +- [ ] PoC #3: 시나리오 스크립트 3~4종 확정 (에스컬레이션 케이스 반드시 포함) +- [ ] PoC #3: "상대방" 역할 참가자 5~10명 확보 +- [ ] 두 PoC 결과를 `decision-log.md`/`risk-log.md`에 반영 From 210432747b65b6aa79d7fe0206a9f1d38a9d499b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:35:15 +0000 Subject: [PATCH 06/33] Note the clickable prototype in the planning docs Built an interactive click-through prototype covering the read-receipt, identity-confirmation/veto, and escalation scenarios plus the autonomy settings screen. Link it into PLANNING.md's checklist and poc-plan.md so PoC #3 role-play can reuse it as stimulus material instead of building scenario scripts from scratch. --- docs/PLANNING.md | 4 +++- docs/poc-plan.md | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 2da123b..db7603d 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -117,6 +117,8 @@ - [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md` (계획만 작성됨, 실제 참가자 모집·데이터 수집·인터뷰는 아직 실행 전 — `poc-plan.md` §4 참고) - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 -- [ ] 클릭 가능한 프로토타입(Figma 등) 제작, 뱃지·거부권 UX 포함 +- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 + 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). + PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 - [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 (PoC #3와 통합 진행 가능) - [ ] 실제 회의에서 이 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 diff --git a/docs/poc-plan.md b/docs/poc-plan.md index dbef85f..ca52e40 100644 --- a/docs/poc-plan.md +++ b/docs/poc-plan.md @@ -42,11 +42,13 @@ 실제로 상대방의 신뢰를 지키는지, 설계 문서 밖에서 확인한다. ### 방법 -1. **역할극 시나리오 스크립트 작성**: `PRD.md` §2.2 읽씹 종결 흐름을 그대로 재현하는 대화 스크립트 - 3~4종 (일반적인 안부, 업무 문의, 약속 잡기 시도, 감정적인 대화 — 에스컬레이션 되어야 하는 경우 포함) -2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 시나리오로 분신과 대화하게 함 (Wizard-of-Oz - 방식 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신 수행해도 됨, 이 단계에서는 UX/신뢰 - 반응만 측정) +1. **자극재 준비**: 읽씹 종결·본인확인·거부권·에스컬레이션 4개 장면을 실제로 눌러볼 수 있는 클릭 + 프로토타입을 이미 제작해둠 (`PLANNING.md` §8 참고). 역할극에서 이 프로토타입을 그대로 보여주며 + "이런 상황이면 어떨 것 같은지" 반응을 끌어내는 자극재로 사용한다. 추가로 필요하면 감정적인 + 대화 등 프로토타입에 없는 케이스만 별도 스크립트로 보완 +2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 분신과 대화하는 + 경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신 + 수행해도 됨, 이 단계에서는 UX/신뢰 반응만 측정) 3. **관찰 포인트**: - 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가 - "본인이야 분신이야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가 From e586a3fa9ffea27e0058c354b583695107e07bd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:36:43 +0000 Subject: [PATCH 07/33] Add ready-to-use PoC recruiting/consent/script materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recruiting message and data-consent blurb for PoC #1, a role-play script for the one scenario the click prototype doesn't cover (emotional escalation), and a shared post-session interview guide — so participant recruitment can start without drafting these from scratch. --- docs/PLANNING.md | 4 ++- docs/poc-materials.md | 70 +++++++++++++++++++++++++++++++++++++++++++ docs/poc-plan.md | 9 ++++-- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 docs/poc-materials.md diff --git a/docs/PLANNING.md b/docs/PLANNING.md index db7603d..69d9b30 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -107,6 +107,7 @@ 5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영 6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록 7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 +8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안 ## 8. 다음 액션 체크리스트 @@ -115,7 +116,8 @@ - [x] MVP 시나리오 확정 (읽씹 종결 + 단톡 따라잡기) — `PRD.md` - [x] PRD, 기술 설계서, 리스크 로그, 로드맵 작성 - [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md` - (계획만 작성됨, 실제 참가자 모집·데이터 수집·인터뷰는 아직 실행 전 — `poc-plan.md` §4 참고) +- [x] PoC 실행 준비물(모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지) 초안 작성 — `poc-materials.md` + (문서 준비만 완료. 실제 참가자 모집·데이터 수집·인터뷰 실행은 사람이 직접 해야 하는 단계 — 아직 미착수) - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). diff --git a/docs/poc-materials.md b/docs/poc-materials.md new file mode 100644 index 0000000..ccd7665 --- /dev/null +++ b/docs/poc-materials.md @@ -0,0 +1,70 @@ +# PoC 실행 준비물 + +`poc-plan.md`의 방법론을 실제로 돌리기 위해 바로 복사해서 쓸 수 있는 문구/스크립트 초안. +괄호 안 내용은 실행 담당자가 채워야 하는 부분이다. + +## 1. PoC #1 참가자 모집 문구 + +> 안녕하세요! 요즘 만들고 있는 "분신"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나 +> 잘 따라 하는지 테스트해보려고 해요. 참여 방법은 이렇습니다. +> +> 1. 최근 카톡 대화 내보내기(50~100개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요 +> 2. 며칠 뒤에 "실제 답장 vs AI가 만든 답장"을 섞어서 보여드릴게요 +> 3. 어느 게 진짜인지, 그리고 "이거 나 같다" 싶은지 짧은 설문에 답해주시면 끝! +> +> 10분 정도 걸리고, 원하시면 결과(AI가 얼마나 저를 잘 따라 했는지)도 같이 공유해드릴게요. +> 대화 내용은 이 테스트 외 다른 용도로 쓰지 않고, 끝나면 삭제할게요. + +### 데이터 수집 동의 안내 (필수 포함 문구) +- 수집 목적: 온디바이스 말투 학습 프로토타입 품질 확인 (PoC #1)만 +- 보관 기간: 테스트 종료 후 즉시 삭제, 최대 2주 보관 +- 제3자 공유 없음, 참가자 본인 확인 후 언제든 삭제 요청 가능 +- 민감한 대화(금전, 타인 언급, 사적인 내용)는 참가자가 사전에 직접 제외 + +### 블라인드 평가 설문 (초안) + +| 항목 | 형식 | +|---|---| +| 아래 두 답장 중 실제로 보낸 것을 골라주세요 | 2지선다 (실제 답장 / AI 초안, 순서 랜덤) | +| "이 답장, 나(그 사람) 말투 같다" | 5점 척도 (1 전혀 아니다 ~ 5 매우 그렇다) | +| 어색하게 느껴진 부분이 있다면 자유롭게 | 자유 서술 | +| 실제 나였다면 이 답장을 그대로 보냈을까? | 3지선다 (그대로 보냄 / 조금 고쳐서 보냄 / 안 보냄) | + +## 2. PoC #3 역할극 스크립트 (프로토타입 보완용) + +`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 +다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. + +### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함) + +``` +상대: 오늘 좀 힘든 일이 있었어... 얘기해도 될까 +분신: (감정적으로 무거운 주제로 판단, 보류) + 죄송해요, 이런 얘기는 제가 대신 답하기 어려워서 지우님께 바로 알려드렸어요. + 조금만 기다려주시면 지우님이 직접 답장하실 거예요. +(본인에게 알림: "은채님이 힘든 일이 있다고 하셨어요 — 직접 답장이 필요해요") +``` + +**관찰 포인트**: 분신이 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지, +아니면 오히려 더 신뢰하는지 — 감정 주제에서의 반응은 업무/약속 주제와 다를 수 있음. + +### 역할극 진행 스크립트 (진행자용) + +1. (도입, 1분) "지금부터 짧은 대화를 몇 번 나눠볼 거예요. 상대방이 되어 편하게 대화해주시면 됩니다." +2. (프로토타입 시연, 3분) 4개 탭을 순서대로 같이 보며 진행 — 매 장면 뒤 "지금 느낌이 어땠어요?" 짧게 질문 +3. (시나리오 E 역할극, 3분) 진행자가 분신 역할, 참가자가 상대방 역할로 실제 대화 +4. (사후 인터뷰, 5분) 아래 §3 질문지 사용 + +## 3. 사후 인터뷰 질문지 (PoC #3 공용) + +1. 방금 대화가 불편하게 느껴진 순간이 있었나요? 있다면 어떤 부분이었나요? +2. "본인이야 분신이야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요? +3. 분신이 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요? +4. 거부권("본인이랑만 얘기하고 싶다")을 쓴다면, 언제 쓰고 싶을 것 같나요? +5. 이런 분신과 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요? + +## 참가자 확보 규모 (재확인) + +- PoC #1: 3~5명 (내부 팀/지인) +- PoC #3: 5~10명 ("상대방" 역할), 위 §1과 겹치지 않아도 됨 +- 두 PoC를 같은 날 이어서 진행해도 무방 — 참가자 1인이 양쪽에 다 참여할 수도 있음 diff --git a/docs/poc-plan.md b/docs/poc-plan.md index ca52e40..61f514d 100644 --- a/docs/poc-plan.md +++ b/docs/poc-plan.md @@ -76,8 +76,11 @@ ## 다음 액션 (이 문서 작성 다음 단계) -- [ ] PoC #1: 참가자 3~5명 확보 + 대화 샘플 수집 동의 +바로 쓸 수 있는 모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지는 [`poc-materials.md`](./poc-materials.md)에 준비되어 있다. + +- [x] PoC #1: 참가자 모집 문구 + 데이터 수집 동의 안내 초안 — `poc-materials.md` §1 +- [x] PoC #3: 역할극 스크립트(프로토타입 보완용) + 사후 인터뷰 질문지 초안 — `poc-materials.md` §2~3 +- [ ] PoC #1: 참가자 3~5명 실제 확보 + 대화 샘플 수집 (사람이 직접 섭외해야 하는 단계) - [ ] PoC #1: 응답 초안 생성 프로토타입 준비 (LLM 프롬프트 기반 초기 버전으로 충분) -- [ ] PoC #3: 시나리오 스크립트 3~4종 확정 (에스컬레이션 케이스 반드시 포함) -- [ ] PoC #3: "상대방" 역할 참가자 5~10명 확보 +- [ ] PoC #3: "상대방" 역할 참가자 5~10명 실제 확보 - [ ] 두 PoC 결과를 `decision-log.md`/`risk-log.md`에 반영 From c1476239191d4ca2318a6345d9f669dae0107fab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:38:01 +0000 Subject: [PATCH 08/33] Add Q3 autonomy-acceptance interview guide (twin owner's perspective) poc-materials.md only tests how the recipient reacts to the twin; Q3 asks whether the person delegating to it actually wants to. Adds a separate screening + interview script for that side, with guidance on sequencing it before the peer role-play so the two perspectives' gap becomes a signal in itself. --- docs/PLANNING.md | 5 +++- docs/user-interview-guide.md | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 docs/user-interview-guide.md diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 69d9b30..f897cac 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -108,6 +108,7 @@ 6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록 7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안 +9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (분신 사용자 관점) ## 8. 다음 액션 체크리스트 @@ -122,5 +123,7 @@ - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 -- [ ] 5~10명 대상 유저 인터뷰로 "AI 대리 응답 수용성"(Q3) 검증 (PoC #3와 통합 진행 가능) +- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` + (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) +- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) - [ ] 실제 회의에서 이 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 diff --git a/docs/user-interview-guide.md b/docs/user-interview-guide.md new file mode 100644 index 0000000..52c8a7e --- /dev/null +++ b/docs/user-interview-guide.md @@ -0,0 +1,53 @@ +# 유저 인터뷰 가이드 — Q3 자율성 수용성 (분신 사용자 관점) + +`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 분신을 얼마나 신뢰하는지를 본다. +이 문서는 반대쪽 — **분신을 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다. +`decision-log.md` Q3("자율성 몇 단계까지 출시?")를 "L0~L2만"으로 잠정 결정했는데, 이게 실제 +사용자들의 위임 의향과 맞는지 확인하는 것이 목적이다. 두 인터뷰는 서로 대체하지 않는다 — +분신이 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가 +틀린 것이다. + +## 대상 및 규모 + +- 메신저 응답 압박을 자주 느끀다고 스스로 말하는 개인 5~10명 (`vision.md` 타깃과 동일) +- 기존 AI 비서(챗GPT 등)를 이미 써본 사람과 안 써본 사람을 섞어서 섭외 — 위임 거부감 차이가 + 있는지 비교 가능 + +## 스크리닝 질문 (섭외 단계) + +1. 최근 한 달 안에 "답장을 미뤄서 미안했던" 경험이 있나요? +2. 메신저 대화 중 몇 %가 "꼭 내가 직접 안 해도 되는" 가벼운 대화라고 느끼나요? (대략 %) + +## 본 인터뷰 질문지 + +### 1. 위임 의향 (핵심) +- 자는 동안 온 메시지에 AI가 "지금 자는 중"이라고 대신 답해준다면, 어떤 느낌일 것 같나요? +- 그 응답을 보내기 전에 매번 확인하고 싶나요(L1), 아니면 특정 주제는 확인 없이 바로 보내도 + 괜찮을 것 같나요(L2)? 왜 그 기준인가요? +- "확인 없이 바로 보내도 되는 주제"를 직접 골라본다면 뭐가 있을까요? (화이트리스트 감 잡기) +- 반대로, "절대 AI가 대신 답하면 안 되는" 상대나 주제가 있나요? + +### 2. 신뢰의 조건 +- AI가 대신 답한 걸 나중에 알게 됐을 때, 뭘 보면 "그래도 괜찮았다"고 느낄 것 같나요? + (사후 알림, 되돌리기, 뱃지 등 중 무엇이 중요한지 자연스럽게 끌어내기) +- 위임했다가 되돌리고 싶었던 순간을 상상해본다면 언제일까요? + +### 3. 관계별 차이 +- 가까운 친구/가족과 회사·업무 상대, 위임하고 싶은 정도가 다른가요? 어떻게 다른가요? + +### 4. 확장 의향 (v2 신호, 참고용 — v1 범위 확정에는 안 씀) +- 만약 AI가 약속 시간까지 상대와 조율해서 몇 개 후보만 골라준다면(L4), 쓰고 싶을 것 같나요? + 아니면 그건 좀 부담스러운가요? + +## 결과 정리 방법 + +- 답변을 "위임 희망 주제/상대" 목록으로 정리 → `PRD.md` §3.1 화이트리스트 주제 설계에 반영 +- L0/L1/L2 중 실제 선호 분포를 확인 → 만약 다수가 L1도 부담스러워한다면(L0만 원함), + `decision-log.md` Q3을 재검토해야 함 — 이 경우 즉시 팀에 공유 +- 절대 위임 불가 상대/주제 목록 → `PRD.md` §3.1 에스컬레이션 규칙, `risk-log.md`에 반영 + +## PoC#3과 함께 진행할 때 + +같은 참가자를 양쪽 다 인터뷰해도 된다. 순서는 **이 인터뷰(본인 관점) → `poc-materials.md` +역할극(상대방 관점)** 순으로 진행하는 걸 권장한다 — 먼저 "내가 위임하고 싶은 정도"를 스스로 +답하게 한 뒤에 "상대방 입장"을 시켜보면, 두 관점의 온도차 자체가 흥미로운 신호가 된다. From 1271531a1464601a8efc268fbbb46020c5e8e7b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:39:06 +0000 Subject: [PATCH 09/33] Add one-page meeting review summary and wire up README Consolidates what's ready (doc set, PoC plans/materials, prototype) vs. what still needs a human to execute (recruiting, interviews, Go/No-Go calls), plus a Q1-Q7 confirm/revisit checklist for the actual planning meeting. --- README.md | 8 ++++-- docs/PLANNING.md | 4 ++- docs/meeting-review-summary.md | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 docs/meeting-review-summary.md diff --git a/README.md b/README.md index d500e05..a8b6837 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ - 기술 설계서: [`docs/tech-design.md`](./docs/tech-design.md) - 리스크 로그: [`docs/risk-log.md`](./docs/risk-log.md) - 로드맵: [`docs/roadmap.md`](./docs/roadmap.md) +- PoC #1·#3 실행 계획: [`docs/poc-plan.md`](./docs/poc-plan.md) / 실행 준비물: [`docs/poc-materials.md`](./docs/poc-materials.md) +- Q3 자율성 수용성 인터뷰 가이드: [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) +- 회의 리뷰 1페이지 요약: [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) ## AI 에이전트 규칙 @@ -20,6 +23,7 @@ ## 현재 단계 -아이디어 발산 → 컨셉 심화 → 기획 문서 세트(Vision/PRD/기술설계/리스크/로드맵) 초안 작성까지 완료. +아이디어 발산 → 컨셉 심화 → 기획 문서 세트 작성 → PoC 실행 계획·준비물·클릭 프로토타입 완료. `docs/decision-log.md`의 결정은 아직 **잠정안**이므로, 실제 회의에서 확정/수정이 필요합니다. -다음 단계는 `docs/PLANNING.md` §8 체크리스트의 남은 항목(기술 PoC, 프로토타입, 유저 인터뷰)입니다. +다음 단계는 PoC #1·#3과 Q3 인터뷰를 실제로 진행하는 것입니다 — 계획과 자극재는 이미 준비돼 +있으니 참가자 모집부터 시작하면 됩니다 (`docs/meeting-review-summary.md` 참고). diff --git a/docs/PLANNING.md b/docs/PLANNING.md index f897cac..e7fd92f 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -109,6 +109,7 @@ 7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안 9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (분신 사용자 관점) +10. [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의에서 Q1~Q7을 확정할 때 쓰는 1페이지 요약 ## 8. 다음 액션 체크리스트 @@ -126,4 +127,5 @@ - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) -- [ ] 실제 회의에서 이 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 +- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md` +- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 diff --git a/docs/meeting-review-summary.md b/docs/meeting-review-summary.md new file mode 100644 index 0000000..5a387ec --- /dev/null +++ b/docs/meeting-review-summary.md @@ -0,0 +1,48 @@ +# 회의 리뷰 요약 — Q1~Q7 확정 및 기획 문서 세트 검토용 + +이 회의의 목표: `decision-log.md`의 잠정 결정(Q1~Q7)을 실제로 **확정**하거나 **뒤집는다.** +그 외 문서(PRD, 기술설계 등)는 이 결정들 위에 이미 작성돼 있으므로, Q#이 뒤집히면 해당 문서도 +같이 갱신해야 한다 — 회의 마지막에 담당자와 갱신 범위를 정한다. + +## 지금까지 준비된 것 (한눈에) + +| 문서 | 내용 | 상태 | +|---|---|---| +| `decision-log.md` | Q1~Q7 잠정 결정 + 근거 | **이 회의에서 확정 대상** | +| `vision.md` | 문제/타깃/가치제안/성공지표 | 결정 반영 완료 | +| `PRD.md` | v1 기능 명세, 유저 플로우, 엣지 케이스 | 결정 반영 완료 | +| `tech-design.md` | 아키텍처, 자율성 엔진, 투명성 구현 | 결정 반영 완료 | +| `risk-log.md` | 리스크 + 완화 상태 | 결정 반영 완료 | +| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 | +| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** | +| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** | +| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 | + +**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행, +그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다. + +## Q1~Q7 확정 체크리스트 (회의 중 하나씩) + +각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다. + +- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예 +- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후) +- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설) +- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행) +- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예 +- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전 +- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예 + +## 회의에서 정할 것 (Q1~Q7 확정 외) + +1. **PoC #1·#3 착수 일정** — `poc-materials.md` 모집 문구를 누가, 언제 발송할지 +2. **Q3 인터뷰(`user-interview-guide.md`) 담당자** — PoC#3과 같은 날 진행할지 여부 +3. **Go/No-Go 기준 재확인** — `poc-plan.md`의 수치 기준(예: "말투 같다" 4/5 이상)에 이견 없는지 +4. **다음 리뷰 시점** — PoC 결과가 나오는 대로 재소집할지, 정기 주기로 할지 + +## 회의 후 팔로업 + +- Q#이 확정되면 `decision-log.md`의 "상태" 열을 제안 → 확정으로 갱신 +- Q#이 뒤집히면 `decision-log.md` + 영향받는 하위 문서(§ 위 표 참고)를 같은 커밋에서 함께 수정 + (`AGENTS.md`: "Q1~Q7이 뒤집히면 decision-log.md와 파생 문서를 동시에 갱신" 원칙) +- PoC 실행 담당자·일정이 정해지면 `PLANNING.md` §8 체크리스트에 담당자/일정 메모 추가 From 699ba68f495ad244253182c45a85ff8cc64bee37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:00:31 +0000 Subject: [PATCH 10/33] Add PoC #1 base-corpus preprocessing pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the AI-Hub "한국어 SNS 멀티턴 대화" TL/VL zip parts into clean train/val JSONL (142,575 dialogues), with a QA cross-check against the raw TS/VS source. Script and docs only -- the dataset itself stays out of git per .gitignore, both for size and because AI-Hub's terms restrict redistribution. --- .gitignore | 5 + docs/PLANNING.md | 3 + docs/poc-plan.md | 4 + docs/risk-log.md | 1 + poc/tone-corpus/README.md | 39 +++++++ poc/tone-corpus/prepare_dataset.py | 169 +++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+) create mode 100644 .gitignore create mode 100644 poc/tone-corpus/README.md create mode 100644 poc/tone-corpus/prepare_dataset.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57ee51f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# PoC data — large, and AI-Hub source data may not be redistributed. +# Keep scripts in git; keep the actual datasets out. +poc/tone-corpus/data/ +*.jsonl +*.zip diff --git a/docs/PLANNING.md b/docs/PLANNING.md index e7fd92f..140567e 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -120,6 +120,9 @@ - [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md` - [x] PoC 실행 준비물(모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지) 초안 작성 — `poc-materials.md` (문서 준비만 완료. 실제 참가자 모집·데이터 수집·인터뷰 실행은 사람이 직접 해야 하는 단계 — 아직 미착수) +- [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건 + 확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는 + 아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고) - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). diff --git a/docs/poc-plan.md b/docs/poc-plan.md index 61f514d..dae4dce 100644 --- a/docs/poc-plan.md +++ b/docs/poc-plan.md @@ -34,6 +34,10 @@ - 응답 초안 생성기 (이 세션에서는 프로토타입 수준 — 실제 온디바이스 모델이 아니어도, LLM 프롬프트에 말투 특징을 주입하는 방식으로 초기 신호를 얻을 수 있음. 배터리/성능 검증은 이 PoC의 범위가 아님) - 블라인드 평가지 (설문 형태, 5점 척도 + 자유 코멘트) +- **기반 코퍼스**: AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋(14만여 건)을 확보해 개인화 전 + 일반 톤 생성 모델을 먼저 학습/평가할 수 있게 정리함 — [`poc/tone-corpus/`](../poc/tone-corpus/) + 참고. 이건 개인화 검증(위 방법론)을 대체하지 않는다 — 익명 화자쌍의 일반 대화라 "내 말투 같다"는 + 질문에는 답을 못 준다. 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다. ## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX) diff --git a/docs/risk-log.md b/docs/risk-log.md index d8c7c37..7d7c61c 100644 --- a/docs/risk-log.md +++ b/docs/risk-log.md @@ -14,6 +14,7 @@ | 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 | | (v2 대비) 카카오톡/인스타 알림 파싱이 앱 업데이트로 깨짐 | OS 레이어 확장 시 유지보수 부담 | v1 범위에서 제외, v2 착수 시 안정적 API 채널(문자·이메일) 우선 | v1 범위 밖 — 그대로 유지 | | (v2 대비) iOS 플랫폼 정책 제약 | OS 레이어 iOS 확장 어려움 | 안드로이드 우선 검증 후 iOS는 자체 앱 전환 유도 | v1 범위 밖 — 그대로 유지 | +| PoC #1 기반 코퍼스(AI-Hub) 재배포/보관 리스크 | 이용약관 위반, 데이터 유실 | git에 커밋 금지(`.gitignore` 반영), 원본·가공본 모두 세션 로컬에만 두고 영구 저장소로는 별도 이동 | 설계 반영 — `poc/tone-corpus/README.md` | ## 우선순위 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md new file mode 100644 index 0000000..13e407c --- /dev/null +++ b/poc/tone-corpus/README.md @@ -0,0 +1,39 @@ +# PoC #1 기반 코퍼스 — AI-Hub 한국어 SNS 멀티턴 대화 + +`docs/poc-plan.md`의 PoC #1(온디바이스 말투 학습)에서, 개인화 레이어를 얹기 전 **기반 톤 생성 +모델**을 학습/평가할 코퍼스로 AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋을 사용한다. 이 데이터는 +특정 개인의 말투가 아니라 익명 화자쌍의 일반 대화이므로, PoC #1의 "내 말투 같다" 개인화 검증 +자체를 대체하지는 않는다 — 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다 +(`docs/poc-materials.md` §1 참고). + +## 사용법 + +```bash +python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output <출력 디렉토리> [--qa] +``` + +- `--input`: AI-Hub에서 내려받은 `TS_*.zip`/`TL_*.zip`(학습)·`VS_*.zip`/`VL_*.zip`(검증) 원본 파일이 + 있는 디렉토리. 파일명에 `VL_`/`TL_`이 포함된 zip(라벨링 JSON)만 읽는다 — 여기에 발화 텍스트와 + 화행(speech_act)·슬롯 라벨이 모두 있어서 `VS_`/`TS_`(원천 CSV)는 출력에 필요 없다. +- `--output`: `train.jsonl`, `val.jsonl`, `stats.json`을 쓸 디렉토리. +- `--qa`: 원천 CSV의 대화ID와 라벨 JSON의 대화ID를 교차검증해서 커버리지 차이를 출력. + +## 실행 결과 (2026-07-29, 이 세션에서 실제로 돌려본 값) + +- 총 142,575건 (학습 122,951 / 검증 19,624), 평균 발화 16.5턴/대화 +- 주제 9종, 정치(3,242건)·경제및사회(4,257건)가 다른 주제(1.7만~2.2만건)보다 훨씬 적음 — 주제별 + 균형이 필요하면 샘플링 시 감안할 것 +- 화자 연령/성별 필드 노이즈 81건은 정제 과정에서 `null`로 치환 (버리지 않고 필드만 비움) +- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 **34,030건이 더 있음** (라벨 없이 텍스트만). + 화행/슬롯 라벨이 필요 없는 순수 언어모델링 목적이라면 이 34,030건도 추가로 끌어올 수 있다 — + 다만 이번 스크립트는 라벨 JSON만 다루므로 추가 파서가 필요함 (아직 안 만듦) + +## 반드시 지킬 것 + +- **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상 + 제3자 재배포가 제한되고, 용량도 수백MB~1GB라 저장소에 맞지 않는다. 저장소 루트의 `.gitignore`에 + `poc/tone-corpus/data/`가 등록되어 있으니 원본·출력은 그 아래에 두고 작업할 것. +- 이 저장소는 기획 문서 전용이라, 이 스크립트도 "PoC 도구"로만 취급한다 — 여기서 앱 코드/프레임워크로 + 확장하지 않는다 (`AGENTS.md` 참고). +- 실제 모델 학습에 쓰기 전에, 이 데이터가 세션이 아닌 영구 저장소(본인 로컬/스토리지)로 옮겨졌는지 + 확인할 것 — 클라우드 세션은 종료되면 임시 파일이 사라진다. diff --git a/poc/tone-corpus/prepare_dataset.py b/poc/tone-corpus/prepare_dataset.py new file mode 100644 index 0000000..7d81a29 --- /dev/null +++ b/poc/tone-corpus/prepare_dataset.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Consolidate the AI-Hub "한국어 SNS 멀티턴 대화" TS/TL/VS/VL zip parts into +clean train/val JSONL for PoC #1 (base tone-generation corpus). + +Usage: + python3 prepare_dataset.py --input --output + +Input zips are matched by filename substring: files containing "VL_" or "TL_" +are the labeled JSON splits (VL=validation, TL=train) and are the only ones +consumed for the JSONL output -- they already carry the full utterance text +plus speech_act/slot labels, so the raw CSV (VS_/TS_) is redundant for this +purpose. VS_/TS_ zips are only used for the optional --qa cross-check. + +Does NOT commit or move the source zips anywhere -- run this against a local +copy of the AI-Hub download, and keep both the source zips and this script's +output out of git (see poc/tone-corpus/README.md). +""" +import argparse +import csv +import glob +import io +import json +import os +import zipfile +from collections import Counter + +VALID_SEX = {"남자", "여자"} + + +def is_valid_age(value): + # raw field is a bare decade number as a string, e.g. "20", "30" -- not "20대". + return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0 + + +def clean_speaker_field(value, kind): + if not value: + return None + if kind == "sex": + return value if value in VALID_SEX else None + return value if is_valid_age(value) else None + + +def iter_dialogues(zip_paths): + for path in zip_paths: + split = "val" if "VL_" in os.path.basename(path) else "train" + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if not name.endswith(".json"): + continue + with z.open(name) as f: + yield split, json.load(f) + + +def normalize(dialogue): + info = dialogue.get("info", {}) + speaker = info.get("speaker", {}) + speakers = {} + for letter in ("A", "B", "C"): + sid = speaker.get(f"speaker{letter}Id") + if not sid: + continue + speakers[letter] = { + "id": sid, + "sex": clean_speaker_field(speaker.get(f"speaker{letter}Sex"), "sex"), + "age": clean_speaker_field(speaker.get(f"speaker{letter}Age"), "age"), + } + utterances = [ + { + "speaker": u.get("speaker", "").replace("speaker", ""), + "text": u.get("text", ""), + "speech_act": u.get("speech_act"), + "slot": u.get("slot") or [], + } + for u in dialogue.get("utterances", []) + ] + return { + "id": info.get("id"), + "topic": info.get("topic"), + "keyword": info.get("keyword"), + "speakers": speakers, + "turns": len(utterances), + "utterances": utterances, + } + + +def run_qa(input_dir, seen_ids): + csv_paths = sorted( + glob.glob(os.path.join(input_dir, "*VS_*.zip")) + + glob.glob(os.path.join(input_dir, "*TS_*.zip")) + ) + csv_ids = set() + for path in csv_paths: + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if not name.endswith(".csv"): + continue + with z.open(name) as f: + text = io.TextIOWrapper(f, encoding="utf-8-sig") + for row in csv.DictReader(text): + did = row.get("대화ID") + if did: + csv_ids.add(did) + only_in_json = seen_ids - csv_ids + only_in_csv = csv_ids - seen_ids + print(f"[QA] 원천(CSV) 대화ID 수: {len(csv_ids)}") + print(f"[QA] 라벨(JSON)에만 있음: {len(only_in_json)}") + print(f"[QA] 원천(CSV)에만 있음: {len(only_in_csv)}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", required=True, help="원본 zip 파일들이 있는 디렉토리") + ap.add_argument("--output", required=True, help="train.jsonl/val.jsonl을 쓸 디렉토리") + ap.add_argument("--qa", action="store_true", help="VS/TS 원천 CSV와 대화ID 교차검증") + args = ap.parse_args() + + os.makedirs(args.output, exist_ok=True) + zip_paths = sorted( + glob.glob(os.path.join(args.input, "*VL_*.zip")) + + glob.glob(os.path.join(args.input, "*TL_*.zip")) + ) + if not zip_paths: + raise SystemExit(f"VL_/TL_ zip을 {args.input}에서 찾지 못했습니다.") + + writers = { + "train": open(os.path.join(args.output, "train.jsonl"), "w", encoding="utf-8"), + "val": open(os.path.join(args.output, "val.jsonl"), "w", encoding="utf-8"), + } + topic_counts = Counter() + split_counts = Counter() + dropped_age = 0 + dropped_sex = 0 + seen_ids = set() + + try: + for split, raw in iter_dialogues(zip_paths): + record = normalize(raw) + seen_ids.add(record["id"]) + split_counts[split] += 1 + topic_counts[record["topic"]] += 1 + for s in record["speakers"].values(): + if s["age"] is None: + dropped_age += 1 + if s["sex"] is None: + dropped_sex += 1 + writers[split].write(json.dumps(record, ensure_ascii=False) + "\n") + finally: + for w in writers.values(): + w.close() + + stats = { + "total_dialogues": sum(split_counts.values()), + "split_counts": dict(split_counts), + "topic_counts": dict(topic_counts.most_common()), + "speaker_fields_dropped_as_noise": {"age": dropped_age, "sex": dropped_sex}, + } + with open(os.path.join(args.output, "stats.json"), "w", encoding="utf-8") as f: + json.dump(stats, f, ensure_ascii=False, indent=2) + + print(f"완료: {stats['total_dialogues']}건 -> {args.output}/{{train,val}}.jsonl") + print(f"분할: {stats['split_counts']}") + print(f"이상치로 제외된 speaker 필드 - age: {dropped_age}, sex: {dropped_sex}") + + if args.qa: + run_qa(args.input, seen_ids) + + +if __name__ == "__main__": + main() From 8a4b5ba782f4df109473ecc11fd1f4269b730602 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:27:39 +0000 Subject: [PATCH 11/33] Add response-draft generator prototype for PoC #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_draft.py takes a few style-exemplar messages plus recent conversation context and drafts a reply via LLM call (the server fallback path from tech-design.md §2), with escalation baked into the system prompt for money/appointment/emotional content. Verified prompt construction against a real corpus dialogue and hand-compared a generated draft to the withheld real reply (README "샘플 검증") -- no API key in this session, so the live call itself is untested. --- docs/PLANNING.md | 3 ++ docs/poc-plan.md | 7 ++- poc/tone-corpus/README.md | 25 +++++++++++ poc/tone-corpus/generate_draft.py | 71 +++++++++++++++++++++++++++++++ poc/tone-corpus/requirements.txt | 1 + 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 poc/tone-corpus/generate_draft.py create mode 100644 poc/tone-corpus/requirements.txt diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 140567e..017a57d 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -123,6 +123,9 @@ - [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건 확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는 아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고) +- [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → + LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 + 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). diff --git a/docs/poc-plan.md b/docs/poc-plan.md index dae4dce..461420d 100644 --- a/docs/poc-plan.md +++ b/docs/poc-plan.md @@ -31,8 +31,11 @@ ### 필요한 것 - 참가자 3~5명 (내부 팀/지인으로 충분, 이 단계는 소규모 신호 확인이 목적) -- 응답 초안 생성기 (이 세션에서는 프로토타입 수준 — 실제 온디바이스 모델이 아니어도, LLM 프롬프트에 - 말투 특징을 주입하는 방식으로 초기 신호를 얻을 수 있음. 배터리/성능 검증은 이 PoC의 범위가 아님) +- **응답 초안 생성기** — [`poc/tone-corpus/generate_draft.py`](../poc/tone-corpus/generate_draft.py)로 + 준비됨. 말투 예시 몇 문장 + 최근 대화를 넣으면 LLM 호출로 답장 초안을 만든다 (배터리/성능 검증은 + 이 PoC의 범위가 아니라 서버 LLM 호출로 대체). 참가자 대화 샘플이 들어오면 그 사람의 실제 문장 + 몇 개를 `--style`에 넣어 바로 쓸 수 있음. 실행에는 `ANTHROPIC_API_KEY`가 필요 — 아직 이 세션엔 + 키가 없어 코퍼스 실제 대화로 프롬프트만 확인해둠 (`poc/tone-corpus/README.md` "샘플 검증" 참고) - 블라인드 평가지 (설문 형태, 5점 척도 + 자유 코멘트) - **기반 코퍼스**: AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋(14만여 건)을 확보해 개인화 전 일반 톤 생성 모델을 먼저 학습/평가할 수 있게 정리함 — [`poc/tone-corpus/`](../poc/tone-corpus/) diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 13e407c..8930a05 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -28,6 +28,31 @@ python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output 화행/슬롯 라벨이 필요 없는 순수 언어모델링 목적이라면 이 34,030건도 추가로 끌어올 수 있다 — 다만 이번 스크립트는 라벨 JSON만 다루므로 추가 파서가 필요함 (아직 안 만듦) +## 응답 초안 생성기 (`generate_draft.py`) + +PoC #1의 "응답 초안 생성" 단계를 채우는 프로토타입. 말투 예시(그 사람이 쓴 문장 몇 개) + +최근 대화 맥락을 받아서, `tech-design.md` §2의 서버 LLM 폴백 경로처럼 LLM 호출로 답장 초안 +하나를 만든다. 확정성 있는 내용(금전·약속·감정 이슈)은 `[ESCALATE]`를 출력하도록 시스템 +프롬프트에 못박아뒀다 — `AGENTS.md`의 절대 안전선을 프롬프트 레벨에서도 지키기 위함. + +```bash +pip install -r requirements.txt +export ANTHROPIC_API_KEY=... +python3 generate_draft.py --style style_examples.txt --context context.txt +``` + +`ANTHROPIC_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다 — 이 세션에는 그 +키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만 확인하고 초안은 +직접 사람이 작성해 아래처럼 대조해봤다. + +**샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만 +스타일 예시로 사용): +- 실제 답장: `ㅋㅋ 내가 지금 말한 거 고교학점제 홈피에 다 있는 내용이니까 궁금하면 가서 더 찾아봐랑!ㅋ` +- 생성 초안(수동): `ㅋㅋㅋ 뭘 유식까지야, 그냥 관심있어서 좀 찾아본거임 너도 궁금하면 고교학점제 사이트 가서 찾아봐~ㅋ` +- 둘 다 "ㅋㅋ로 시작 → 정보 제공자 역할 수용 → '찾아봐'식 권유형 마무리 → ㅋ로 끝" 패턴이 겹침. + 다만 이건 익명 화자의 일반 대화 스타일 재현 여부를 본 것일 뿐, 실제 개인화("이 사람 말투 같다") + 검증은 아니다 — 그건 `poc-materials.md`의 실제 참가자 데이터로만 확인 가능하다. + ## 반드시 지킬 것 - **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상 diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py new file mode 100644 index 0000000..21eeb3e --- /dev/null +++ b/poc/tone-corpus/generate_draft.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Generate a tone-matched reply draft -- the "응답 초안 생성기" that PoC #1 +(`docs/poc-plan.md`) blind-evaluates against a person's real reply. + +Takes a few example messages written by the target person (style exemplars) +plus the recent conversation, and asks an LLM to draft the next reply in +that voice. This is the server-fallback path described in +`docs/tech-design.md` §2 ("온디바이스 부족시 서버 LLM 호출, 최소 컨텍스트만 +전송") -- a real on-device model would replace the API call later, but the +prompt contract (exemplars + context in, one draft out) stays the same. + +Usage: + export ANTHROPIC_API_KEY=... + python3 generate_draft.py --style style_examples.txt --context context.txt + +style_examples.txt: one example message per line, written by the target person. +context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending +with the incoming message that needs a reply. +""" +import argparse +import os +import sys + +SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \ +그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라. + +지켜야 할 것: +- 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다. +- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ +[ESCALATE] 이 내용은 본인 확인이 필요합니다. +- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.""" + + +def build_user_prompt(style_examples, context_lines): + examples = "\n".join(f"- {s}" for s in style_examples) + context = "\n".join(context_lines) + return f"""[말투 예시]\n{examples}\n\n[최근 대화]\n{context}\n\n위 대화의 마지막 메시지에 대한 답장 초안:""" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") + ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") + ap.add_argument("--model", default="claude-sonnet-5") + args = ap.parse_args() + + with open(args.style, encoding="utf-8") as f: + style_examples = [l.strip() for l in f if l.strip()] + with open(args.context, encoding="utf-8") as f: + context_lines = [l.strip() for l in f if l.strip()] + + if not os.environ.get("ANTHROPIC_API_KEY"): + print("ANTHROPIC_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", + file=sys.stderr) + print(build_user_prompt(style_examples, context_lines)) + return + + import anthropic # pip install anthropic + + client = anthropic.Anthropic() + resp = client.messages.create( + model=args.model, + max_tokens=300, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": build_user_prompt(style_examples, context_lines)}], + ) + print(resp.content[0].text.strip()) + + +if __name__ == "__main__": + main() diff --git a/poc/tone-corpus/requirements.txt b/poc/tone-corpus/requirements.txt new file mode 100644 index 0000000..2db0540 --- /dev/null +++ b/poc/tone-corpus/requirements.txt @@ -0,0 +1 @@ +anthropic>=0.40.0 From 6d9738e1533ff15d5fa59747ff5642969c82bff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:34:46 +0000 Subject: [PATCH 12/33] Switch generate_draft.py from Anthropic to Gemini Uses google-genai + GEMINI_API_KEY instead of the Anthropic SDK, per product decision to run the server-fallback LLM on Gemini. Same prompt contract and escalation behavior; only the client/env var name changed. README documents where to actually set the key. --- poc/tone-corpus/README.md | 20 ++++++++++++++++---- poc/tone-corpus/generate_draft.py | 23 ++++++++++++----------- poc/tone-corpus/requirements.txt | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 8930a05..01de4b8 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -37,13 +37,25 @@ PoC #1의 "응답 초안 생성" 단계를 채우는 프로토타입. 말투 예 ```bash pip install -r requirements.txt -export ANTHROPIC_API_KEY=... +export GEMINI_API_KEY=... python3 generate_draft.py --style style_examples.txt --context context.txt ``` -`ANTHROPIC_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다 — 이 세션에는 그 -키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만 확인하고 초안은 -직접 사람이 작성해 아래처럼 대조해봤다. +Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은 +`gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다. +이 세션에는 그 키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만 +확인하고 초안은 직접 사람이 작성해 아래처럼 대조해봤다. + +### `GEMINI_API_KEY`는 어디에 설정하나 + +- **본인 로컬 환경에서 이 스크립트를 실제로 돌릴 거라면**: 그쪽 터미널에서 + `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필/`.env` 파일(영구)에 등록. Google AI Studio에서 + 발급한 키를 그대로 쓰면 된다. +- **이 Claude Code 세션/환경에서 직접 실행해보고 싶다면**: 이 대화창에 `export GEMINI_API_KEY=실제키`를 + 실행해달라고 하면 되는데, 그러면 **키 값이 이 대화 기록에 그대로 남는다** — 무제한 결제 키가 아니라 + 사용량 제한을 걸어둔 테스트용 키를 쓰는 걸 권장한다. 대화 기록에 남기고 싶지 않다면, 이 환경(Claude + Code on the web)의 환경설정에서 환경변수로 등록하는 방법도 있다 — 다만 이미 실행 중인 이 세션에 + 즉시 반영되는지는 환경 재시작이 필요할 수 있어 확실치 않다. **샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만 스타일 예시로 사용): diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index 21eeb3e..68d2844 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -10,7 +10,7 @@ that voice. This is the server-fallback path described in prompt contract (exemplars + context in, one draft out) stays the same. Usage: - export ANTHROPIC_API_KEY=... + export GEMINI_API_KEY=... python3 generate_draft.py --style style_examples.txt --context context.txt style_examples.txt: one example message per line, written by the target person. @@ -41,7 +41,7 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") - ap.add_argument("--model", default="claude-sonnet-5") + ap.add_argument("--model", default="gemini-2.5-flash") args = ap.parse_args() with open(args.style, encoding="utf-8") as f: @@ -49,22 +49,23 @@ def main(): with open(args.context, encoding="utf-8") as f: context_lines = [l.strip() for l in f if l.strip()] - if not os.environ.get("ANTHROPIC_API_KEY"): - print("ANTHROPIC_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", file=sys.stderr) print(build_user_prompt(style_examples, context_lines)) return - import anthropic # pip install anthropic + from google import genai # pip install google-genai + from google.genai import types - client = anthropic.Anthropic() - resp = client.messages.create( + client = genai.Client(api_key=api_key) + resp = client.models.generate_content( model=args.model, - max_tokens=300, - system=SYSTEM_PROMPT, - messages=[{"role": "user", "content": build_user_prompt(style_examples, context_lines)}], + contents=build_user_prompt(style_examples, context_lines), + config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300), ) - print(resp.content[0].text.strip()) + print(resp.text.strip()) if __name__ == "__main__": diff --git a/poc/tone-corpus/requirements.txt b/poc/tone-corpus/requirements.txt index 2db0540..e151155 100644 --- a/poc/tone-corpus/requirements.txt +++ b/poc/tone-corpus/requirements.txt @@ -1 +1 @@ -anthropic>=0.40.0 +google-genai>=1.0.0 From 7307cb56bba7702fae6339e8fe3ebb8b60a0e0d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:37:18 +0000 Subject: [PATCH 13/33] Add unlabeled-corpus extractor (B-2) build_unlabeled_corpus.py pulls the 34,030 raw-source dialogues that never got speech_act/slot labels (found via the earlier QA cross-check) into their own unlabeled.jsonl, forward-filling the per-dialogue metadata that AI-Hub's CSV export only writes on each dialogue's first row. Verified against the actual data: 176,605 raw - 142,575 labeled = 34,030, matches exactly. --- docs/PLANNING.md | 2 + poc/tone-corpus/README.md | 10 +- poc/tone-corpus/build_unlabeled_corpus.py | 114 ++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 poc/tone-corpus/build_unlabeled_corpus.py diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 017a57d..d3f3590 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -123,6 +123,8 @@ - [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건 확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는 아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고) +- [x] 라벨 없는 원천 대화 34,030건 추가 확보 — `poc/tone-corpus/build_unlabeled_corpus.py`로 + `unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용) - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 01de4b8..5f9b8d1 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -24,9 +24,13 @@ python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output - 주제 9종, 정치(3,242건)·경제및사회(4,257건)가 다른 주제(1.7만~2.2만건)보다 훨씬 적음 — 주제별 균형이 필요하면 샘플링 시 감안할 것 - 화자 연령/성별 필드 노이즈 81건은 정제 과정에서 `null`로 치환 (버리지 않고 필드만 비움) -- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 **34,030건이 더 있음** (라벨 없이 텍스트만). - 화행/슬롯 라벨이 필요 없는 순수 언어모델링 목적이라면 이 34,030건도 추가로 끌어올 수 있다 — - 다만 이번 스크립트는 라벨 JSON만 다루므로 추가 파서가 필요함 (아직 안 만듦) +- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 34,030건이 더 있었음 (라벨 없이 텍스트만). + `build_unlabeled_corpus.py`로 그 34,030건만 뽑아 `unlabeled.jsonl`(63MB)로 정리함 — 화행/슬롯 + 없이 화자·발화 텍스트만 있어서 순수 언어모델링(다음 문장 예측 등)용으로만 쓸 것 + +```bash +python3 build_unlabeled_corpus.py --input <원본 zip 디렉토리> --output <출력 디렉토리> +``` ## 응답 초안 생성기 (`generate_draft.py`) diff --git a/poc/tone-corpus/build_unlabeled_corpus.py b/poc/tone-corpus/build_unlabeled_corpus.py new file mode 100644 index 0000000..efd35d8 --- /dev/null +++ b/poc/tone-corpus/build_unlabeled_corpus.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Pull the dialogues that exist in the AI-Hub raw source (VS_/TS_ CSV) but +were never labeled (VL_/TL_ JSON) -- 34,030 of them, per the --qa check in +prepare_dataset.py. No speech_act/slot labels are available for these, so +they're only useful for plain language-modeling, not the labeled fields +`generate_draft.py`'s prompt doesn't use anyway. + +Usage: + python3 build_unlabeled_corpus.py --input --output +""" +import argparse +import csv +import glob +import io +import json +import os +import zipfile +from collections import OrderedDict + +VALID_SEX = {"남자", "여자"} + + +def is_valid_age(value): + return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0 + + +def clean(value, kind): + if not value: + return None + if kind == "sex": + return value if value in VALID_SEX else None + return value if is_valid_age(value) else None + + +def labeled_ids(input_dir): + ids = set() + for path in glob.glob(os.path.join(input_dir, "*VL_*.zip")) + glob.glob( + os.path.join(input_dir, "*TL_*.zip") + ): + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if name.endswith(".json"): + with z.open(name) as f: + ids.add(json.load(f)["info"]["id"]) + return ids + + +def read_csv_dialogues(input_dir): + dialogues = OrderedDict() + csv_paths = sorted( + glob.glob(os.path.join(input_dir, "*VS_*.zip")) + glob.glob(os.path.join(input_dir, "*TS_*.zip")) + ) + for path in csv_paths: + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if not name.endswith(".csv"): + continue + with z.open(name) as f: + text = io.TextIOWrapper(f, encoding="utf-8-sig") + current = None + for row in csv.DictReader(text): + if row.get("대화ID"): + did = row["대화ID"] + speakers = {} + for letter in ("A", "B", "C"): + sid = row.get(f"화자{letter} ID") + if not sid: + continue + speakers[letter] = { + "id": sid, + "sex": clean(row.get(f"화자{letter} 성별"), "sex"), + "age": clean(row.get(f"화자{letter} 연령대"), "age"), + } + current = { + "id": did, + "topic": row.get("주제"), + "keyword": row.get("키워드"), + "speakers": speakers, + "utterances": [], + } + dialogues[did] = current + if current is None: + continue + current["utterances"].append( + {"speaker": row.get("발화자", ""), "text": row.get("발화", "")} + ) + return dialogues + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", required=True) + ap.add_argument("--output", required=True) + args = ap.parse_args() + + os.makedirs(args.output, exist_ok=True) + already_labeled = labeled_ids(args.input) + all_dialogues = read_csv_dialogues(args.input) + + out_path = os.path.join(args.output, "unlabeled.jsonl") + kept = 0 + with open(out_path, "w", encoding="utf-8") as out: + for did, d in all_dialogues.items(): + if did in already_labeled: + continue + d["turns"] = len(d["utterances"]) + out.write(json.dumps(d, ensure_ascii=False) + "\n") + kept += 1 + + print(f"원천 전체: {len(all_dialogues)}건 / 라벨링됨(제외): {len(already_labeled)}건 / 출력: {kept}건 -> {out_path}") + + +if __name__ == "__main__": + main() From 3b9a4fcd769c9e659a0184085df0c3d346dd8faa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 08:37:29 +0000 Subject: [PATCH 14/33] Add root .env template for GEMINI_API_KEY Provide .env.example, ignore local .env, and teach generate_draft.py to load the repo-root .env so PoC #1 can use Gemini without exporting keys in the shell every time. Co-authored-by: okuma --- .env.example | 3 +++ .gitignore | 5 +++++ poc/tone-corpus/README.md | 5 +++-- poc/tone-corpus/generate_draft.py | 25 +++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..070cc05 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and fill in values. Never commit .env. +# Get a key from Google AI Studio: https://aistudio.google.com/apikey +GEMINI_API_KEY= diff --git a/.gitignore b/.gitignore index 57ee51f..07ca94e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ poc/tone-corpus/data/ *.jsonl *.zip + +# Local secrets (API keys). Commit .env.example only. +.env +.env.* +!.env.example diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 01de4b8..40e8cf9 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -48,8 +48,9 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 ### `GEMINI_API_KEY`는 어디에 설정하나 -- **본인 로컬 환경에서 이 스크립트를 실제로 돌릴 거라면**: 그쪽 터미널에서 - `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필/`.env` 파일(영구)에 등록. Google AI Studio에서 +- **권장**: 저장소 루트 `.env`에 `GEMINI_API_KEY=...`를 넣는다 (템플릿은 `.env.example`). + `generate_draft.py`가 실행 시 이 파일을 읽는다. `.env`는 `.gitignore`로 커밋되지 않는다. +- **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서 발급한 키를 그대로 쓰면 된다. - **이 Claude Code 세션/환경에서 직접 실행해보고 싶다면**: 이 대화창에 `export GEMINI_API_KEY=실제키`를 실행해달라고 하면 되는데, 그러면 **키 값이 이 대화 기록에 그대로 남는다** — 무제한 결제 키가 아니라 diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index 68d2844..a3d0b24 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -10,6 +10,7 @@ that voice. This is the server-fallback path described in prompt contract (exemplars + context in, one draft out) stays the same. Usage: + # Prefer repo-root .env (GEMINI_API_KEY=...), or: export GEMINI_API_KEY=... python3 generate_draft.py --style style_examples.txt --context context.txt @@ -20,6 +21,28 @@ with the incoming message that needs a reply. import argparse import os import sys +from pathlib import Path + + +def load_dotenv_if_present(): + """Load KEY=VALUE pairs from the nearest .env (repo root preferred).""" + if os.environ.get("GEMINI_API_KEY"): + return + here = Path(__file__).resolve() + candidates = [here.parent / ".env", here.parent.parent.parent / ".env", Path.cwd() / ".env"] + for env_path in candidates: + if not env_path.is_file(): + continue + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("'").strip('"') + if key and key not in os.environ: + os.environ[key] = value + break + SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \ 그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라. @@ -44,6 +67,8 @@ def main(): ap.add_argument("--model", default="gemini-2.5-flash") args = ap.parse_args() + load_dotenv_if_present() + with open(args.style, encoding="utf-8") as f: style_examples = [l.strip() for l in f if l.strip()] with open(args.context, encoding="utf-8") as f: From 623f850ed1a1400c076ec4f2fa429a616b7bc2bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:38:37 +0000 Subject: [PATCH 15/33] Design the personalization layer (B-3) v1 doesn't train a custom model: it retrieves the closest-matching past messages from the person's own history and feeds them as few-shot exemplars to the same prompt contract generate_draft.py already implements, via the hosted Gemini call. Narrows the AI-Hub base corpus's role to evaluation and future on-device distillation, since a hosted LLM already covers general Korean fluency. --- docs/PLANNING.md | 2 ++ docs/poc-plan.md | 3 +++ docs/tech-design.md | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index d3f3590..5264236 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -125,6 +125,8 @@ 아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고) - [x] 라벨 없는 원천 대화 34,030건 추가 확보 — `poc/tone-corpus/build_unlabeled_corpus.py`로 `unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용) +- [x] 개인화 레이어 설계 구체화 — `tech-design.md` §2-1 (v1은 커스텀 학습 없이 과거 발화 검색 + + few-shot, 기반 코퍼스는 평가/v2 온디바이스 증류용으로 역할 한정) - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 diff --git a/docs/poc-plan.md b/docs/poc-plan.md index 461420d..d9c7ca1 100644 --- a/docs/poc-plan.md +++ b/docs/poc-plan.md @@ -41,6 +41,9 @@ 일반 톤 생성 모델을 먼저 학습/평가할 수 있게 정리함 — [`poc/tone-corpus/`](../poc/tone-corpus/) 참고. 이건 개인화 검증(위 방법론)을 대체하지 않는다 — 익명 화자쌍의 일반 대화라 "내 말투 같다"는 질문에는 답을 못 준다. 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다. +- **개인화 메커니즘**: v1은 커스텀 모델 학습이 아니라 그 사람의 과거 발화를 검색해 few-shot으로 + 주입하는 방식 — `tech-design.md` §2-1에 구체화. PoC #1에서 수집한 참가자 샘플이 바로 이 메커니즘의 + "말투 예시"가 된다. ## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX) diff --git a/docs/tech-design.md b/docs/tech-design.md index c73b4cb..854c3c6 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -30,6 +30,30 @@ 서버 왕복 지연이나 서버 장애에 영향받지 않아야 안전선이 항상 지켜진다. - **대화방 저장/릴레이는 서버.** 멀티 디바이스 동기화, 상대방에게 메시지를 전달하는 기본 기능. +### 2-1. 개인화 레이어 — 실제로 어떻게 "그 사람 말투"가 되는가 + +v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 기반 few-shot**으로 개인화한다 — +[`poc/tone-corpus/generate_draft.py`](../poc/tone-corpus/generate_draft.py)가 이미 구현한 +"말투 예시 + 대화 맥락 → LLM 호출" 방식을 그대로 쓰되, 말투 예시를 고정 목록이 아니라 +**그 순간 대화와 가장 비슷한 과거 발화 5~8개를 그 사람의 메시지 이력에서 검색해 넣는다.** + +1. **온디바이스**: 사용자의 과거 메시지(온보딩 시 임포트한 50~100개 + 계속 쌓이는 실사용 이력)를 + 기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일) +2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성 + 기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩 + 인프라를 먼저 만들지 않는다) +3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버 + LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로 + 교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다 + +**기반 코퍼스(AI-Hub 14.2만 건, `poc/tone-corpus/`)의 역할은 이 개인화 메커니즘 자체가 아니다.** +호스팅된 LLM(Gemini)이 이미 일반적인 한국어 대화 유창성을 갖고 있어서, v1에 별도로 코퍼스를 +학습시킬 필요가 없다. 이 코퍼스는 두 가지로만 쓴다: +- **평가**: 검증셋으로 "일반적인 한국어 SNS 대화로서 얼마나 자연스러운가"를 벤치마크 (개인화 + 여부와 무관한 기초 품질 체크 — `poc/tone-corpus/README.md` "샘플 검증"이 그 예) +- **v2 이후 온디바이스 증류 대비**: 배터리/지연/프라이버시 압박으로 자체 경량 모델이 필요해지면, + 이 코퍼스가 그 모델의 기반 학습 데이터가 된다. v1 시점에는 착수하지 않는다 + ## 3. 자율성 엔진 (L0~L2) 1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류) From bb720f01786b391f9f300632ca30d0709b5b0779 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:56:12 +0000 Subject: [PATCH 16/33] Add rule-based escalation gate before any LLM call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .gitignore | 3 + docs/PLANNING.md | 2 + docs/risk-log.md | 2 +- docs/tech-design.md | 5 ++ poc/tone-corpus/README.md | 18 +++++ poc/tone-corpus/escalation_filter.py | 104 +++++++++++++++++++++++++++ poc/tone-corpus/generate_draft.py | 23 +++++- 7 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 poc/tone-corpus/escalation_filter.py 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", From e05cd392ccfbb7bd0d425ccb4961e44f4c705c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:20:41 +0000 Subject: [PATCH 17/33] Add batch blind-eval harness and style retrieval, verified end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blind_eval.py runs PoC #1's blind-eval methodology over N held-out corpus dialogues automatically (generate_draft.py refactored to expose draft_reply() so both share the same drafting logic). retrieve_style.py implements the keyword/recency search from tech-design.md §2-1 and wires into generate_draft.py as --history, replacing hand-curated --style files. Bash access was intermittently restricted for part of this session (auto-mode safety classifier), so these were initially written and committed-pending without live execution. Now verified for real: generate_draft.py's existing behavior still holds after the draft_reply() refactor, blind_eval.py runs cleanly against val.jsonl, and retrieve_style.py's original weighted-sum scoring had a real bug -- recency drowned out keyword overlap for short Korean messages (particle attachment means "핀란드" and "핀란드는" don't share a token), so it was effectively returning the most recent messages regardless of topic. Fixed by ranking on (overlap, recency) instead of a weighted sum, confirmed the Finland-related exemplar now ranks first for a matching query. --- docs/PLANNING.md | 13 ++- docs/tech-design.md | 3 +- poc/tone-corpus/README.md | 57 +++++++++++-- poc/tone-corpus/blind_eval.py | 130 ++++++++++++++++++++++++++++++ poc/tone-corpus/generate_draft.py | 75 +++++++++++------ poc/tone-corpus/retrieve_style.py | 64 +++++++++++++++ 6 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 poc/tone-corpus/blind_eval.py create mode 100644 poc/tone-corpus/retrieve_style.py diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 746da2c..099b887 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -130,9 +130,18 @@ - [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` 설정 후 가능 + Gemini 호출로 초안 생성, 에스컬레이션 케이스는 하드 게이트로 거절). Cursor 환경에서 실제 + `GEMINI_API_KEY`로 라이브 호출 성공 확인함 (스타일 예시 반말·ㅋ톤에 맞는 짧은 답장 초안 생성) +- [x] 블라인드 평가 자동화 스크립트 — `poc/tone-corpus/blind_eval.py`, 검증셋 대화를 자동으로 + held-out 처리해 초안 생성 + 실제 답장을 나란히 리포트. 이 세션의 실행 도구 제한으로 직접 + 돌려보진 못함 — 실행 전 가벼운 샘플(`--n 5`)로 먼저 확인할 것 +- [x] 말투 검색기(retrieval) 구현 — `poc/tone-corpus/retrieve_style.py` (키워드 자카드 유사도 + + 최근성 가중치, 임베딩 없이 v1 설계 그대로). `generate_draft.py --history`로 연결해 스타일 + 예시를 손으로 안 골라도 자동 검색되게 함 - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 +- [x] `blind_eval.py`, `retrieve_style.py`, `generate_draft.py --history` 연동 실제 실행 검증 — + 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` + "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 diff --git a/docs/tech-design.md b/docs/tech-design.md index 11f0143..265404b 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -41,7 +41,8 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일) 2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성 기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩 - 인프라를 먼저 만들지 않는다) + 인프라를 먼저 만들지 않는다). 구현: [`poc/tone-corpus/retrieve_style.py`](../poc/tone-corpus/retrieve_style.py) + — 자카드 유사도 + 최근성 가중치. `generate_draft.py --history`로 바로 연결됨 3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버 LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로 교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 1a28045..4b1b214 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -47,8 +47,10 @@ python3 generate_draft.py --style style_examples.txt --context context.txt Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은 `gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다. -이 세션에는 그 키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만 -확인하고 초안은 직접 사람이 작성해 아래처럼 대조해봤다. + +**Cursor 환경에서 `.env`에 실제 키를 넣고 라이브 호출까지 성공 확인됨** (2026-07-29) — 스타일 +예시(반말·ㅋ톤)에 맞는 짧은 캐주얼 답장 초안이 그대로 나왔다. 이 Claude Code 세션에는 키가 없어서, +동일한 검증은 이 세션에서는 프롬프트 구성 확인 + 사람이 직접 쓴 초안 대조로 대체했다 (아래 참고). ### `GEMINI_API_KEY`는 어디에 설정하나 @@ -56,11 +58,8 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 `generate_draft.py`가 실행 시 이 파일을 읽는다. `.env`는 `.gitignore`로 커밋되지 않는다. - **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서 발급한 키를 그대로 쓰면 된다. -- **이 Claude Code 세션/환경에서 직접 실행해보고 싶다면**: 이 대화창에 `export GEMINI_API_KEY=실제키`를 - 실행해달라고 하면 되는데, 그러면 **키 값이 이 대화 기록에 그대로 남는다** — 무제한 결제 키가 아니라 - 사용량 제한을 걸어둔 테스트용 키를 쓰는 걸 권장한다. 대화 기록에 남기고 싶지 않다면, 이 환경(Claude - Code on the web)의 환경설정에서 환경변수로 등록하는 방법도 있다 — 다만 이미 실행 중인 이 세션에 - 즉시 반영되는지는 환경 재시작이 필요할 수 있어 확실치 않다. +- 대화창에 직접 키 값을 붙여넣는 건 대화 기록에 그대로 남으므로 권장하지 않는다 — 위 두 방법 중 + 하나로, 사용량 제한을 걸어둔 키를 쓸 것. **샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만 스타일 예시로 사용): @@ -88,6 +87,50 @@ python3 escalation_filter.py --selftest # 내장 테스트 케이스 10개, 10 요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용 로그로 계속 튜닝한다 (`docs/tech-design.md` §3, `docs/risk-log.md` 참고). +## 블라인드 평가 자동화 (`blind_eval.py`) + +`docs/poc-plan.md`의 블라인드 평가를 검증셋 전체에 대해 자동으로 돌려보는 배치 스크립트. 각 +대화의 마지막 발화를 가리고, 그 화자의 앞선 발화를 말투 예시로 써서 `generate_draft.draft_reply`로 +초안을 생성한 뒤 실제 발화와 나란히 JSONL로 남긴다. + +```bash +python3 blind_eval.py --corpus --n 30 --output eval_report.jsonl +``` + +**주의 — 이건 진짜 PoC #1이 아니다.** 익명 화자쌍의 일반 대화라 "내 말투 같다"를 판단할 수 없다. +자동 계산하는 `length_ratio`(길이 비율), `formal_match`(존댓말/반말 일치 여부)도 사람이 5점 척도로 +평가하는 진짜 지표의 대체재가 아니라, **대량으로 돌려서 파이프라인 자체가 말이 되는 응답을 +내는지**(요청한 형식으로 나오는지, 에스컬레이션이 과하게/적게 걸리는지) 보는 사전 체크용이다. +진짜 개인화 평가는 `poc-materials.md` §1로 실제 참가자 데이터를 모은 뒤에만 가능하다. + +`--n 5`로 실제 검증셋에 돌려 확인함 — 샘플링, 컨텍스트/스타일 예시 구성, JSONL 출력 전부 정상 +동작 (API 키 없이 `no_key` 상태로 5/5 처리됨, 리포트 레코드 형식도 의도대로 나옴). + +## 말투 검색기 (`retrieve_style.py`) + +`docs/tech-design.md` §2-1에서 설계한 "가벼운 키워드/최근성 기반 검색"의 실제 구현. 그 사람의 +과거 발화 전체(오래된 것부터 한 줄에 하나)와 지금 답장해야 할 메시지를 주면, 키워드 겹침(자카드 +유사도) + 최근성 가중치로 점수를 매겨 가장 비슷한 발화 상위 k개를 돌려준다. 임베딩은 쓰지 않는다 — +"지금부터 임베딩 인프라를 먼저 만들지 않는다"는 설계 원칙을 그대로 따름. + +```bash +python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6 +``` + +`generate_draft.py`에 그대로 연결되어 있다 — `--style` 대신 `--history`를 주면 매번 스타일 예시를 +손으로 고르지 않고 자동으로 검색해서 쓴다: + +```bash +python3 generate_draft.py --history history.txt --context context.txt +``` + +**버그를 하나 잡음**: 처음엔 `overlap + RECENCY_WEIGHT * recency`로 점수를 더했는데, 실제로 +돌려보니 짧은 한국어 문장은 조사가 붙어서("핀란드" vs "핀란드는") 토큰이 잘 안 겹쳐 자카드 +유사도가 거의 항상 최근성 항보다 작았다 — 결과적으로 검색이 사실상 "그냥 최신 메시지 K개"가 +되는 문제였다. `(overlap, recency)` 튜플로 정렬해서 **키워드가 실제로 겹치는 게 항상 먼저 오고, +동점일 때만 최근성으로 갈리게** 고쳤다. 검증: `--query "핀란드는 교육이 좋대"`로 돌리니 겹치는 +토큰이 있는 과거 발화가 1위로 올라오고, 나머지는 최근 순으로 정렬됨을 확인. + ## 반드시 지킬 것 - **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상 diff --git a/poc/tone-corpus/blind_eval.py b/poc/tone-corpus/blind_eval.py new file mode 100644 index 0000000..ab99b99 --- /dev/null +++ b/poc/tone-corpus/blind_eval.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Batch version of PoC #1's blind evaluation (`docs/poc-plan.md`): hold out +each dialogue's last utterance, draft a reply from the rest via +`generate_draft.draft_reply`, and report it next to the real one. + +This is still the general AI-Hub corpus, not a real person's messages -- +so it can only sanity-check "does the pipeline produce plausible Korean +SNS replies," not "does this feel like me." The real PoC #1 blind eval +(human raters, 5-point scale) still needs actual participant data per +`docs/poc-materials.md` §1. Treat this as a pre-check, not a substitute. + +Usage: + python3 blind_eval.py --corpus --n 30 \ + --output eval_report.jsonl + +Without GEMINI_API_KEY set, each sample's status will be "no_key" and the +report just records what would have been sent -- useful for validating the +sampling logic itself before spending API calls. +""" +import argparse +import json +import random +import re +import sys + +from generate_draft import draft_reply, load_dotenv_if_present + +MIN_TURNS = 6 +MIN_STYLE_EXAMPLES = 3 +FORMAL_ENDING = re.compile(r"(요|니다)[.!?~ㅋㅎㅠㅜ]*$") + + +def build_sample(dialogue): + utts = dialogue.get("utterances", []) + if len(utts) < MIN_TURNS: + return None + + held_speaker = utts[-1]["speaker"] + real_reply = utts[-1]["text"] + context_lines = [] + style_examples = [] + for u in utts[:-1]: + label = "나" if u["speaker"] == held_speaker else "상대" + context_lines.append(f"{label}: {u['text']}") + if u["speaker"] == held_speaker: + style_examples.append(u["text"]) + + if len(style_examples) < MIN_STYLE_EXAMPLES: + return None + + return { + "id": dialogue.get("id"), + "topic": dialogue.get("topic"), + "style_examples": style_examples, + "context_lines": context_lines, + "real_reply": real_reply, + } + + +def is_formal(text): + return bool(FORMAL_ENDING.search(text.strip())) + + +def evaluate(sample, model): + status, text = draft_reply(sample["style_examples"], sample["context_lines"], model=model) + record = { + "id": sample["id"], + "topic": sample["topic"], + "real_reply": sample["real_reply"], + "status": status, + } + if status == "ok": + record["generated_draft"] = text + record["length_ratio"] = round(len(text) / max(len(sample["real_reply"]), 1), 2) + record["formal_match"] = is_formal(text) == is_formal(sample["real_reply"]) + elif status == "escalate": + record["escalation_reason"] = text + else: + record["prompt_preview"] = text + return record + + +def summarize(records): + total = len(records) + counts = {} + for r in records: + counts[r["status"]] = counts.get(r["status"], 0) + 1 + ok = [r for r in records if r["status"] == "ok"] + summary = {"total": total, "status_counts": counts} + if ok: + summary["avg_length_ratio"] = round(sum(r["length_ratio"] for r in ok) / len(ok), 2) + summary["formal_match_rate"] = round(sum(r["formal_match"] for r in ok) / len(ok), 2) + return summary + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", required=True, help="val.jsonl 또는 train.jsonl 경로") + ap.add_argument("--n", type=int, default=30, help="평가할 대화 수") + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--model", default="gemini-2.5-flash") + ap.add_argument("--output", required=True, help="결과를 쓸 JSONL 경로") + args = ap.parse_args() + + load_dotenv_if_present() + + with open(args.corpus, encoding="utf-8") as f: + dialogues = [json.loads(line) for line in f if line.strip()] + + samples = [s for s in (build_sample(d) for d in dialogues) if s] + if not samples: + raise SystemExit(f"{MIN_TURNS}턴 이상, 화자 발화 {MIN_STYLE_EXAMPLES}개 이상인 대화가 없습니다.") + + rng = random.Random(args.seed) + chosen = rng.sample(samples, min(args.n, len(samples))) + + records = [] + with open(args.output, "w", encoding="utf-8") as out: + for i, sample in enumerate(chosen, 1): + record = evaluate(sample, args.model) + records.append(record) + out.write(json.dumps(record, ensure_ascii=False) + "\n") + print(f"[{i}/{len(chosen)}] {record['id']} status={record['status']}", file=sys.stderr) + + summary = summarize(records) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index f6ac001..767154e 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -13,10 +13,14 @@ Usage: # Prefer repo-root .env (GEMINI_API_KEY=...), or: export GEMINI_API_KEY=... python3 generate_draft.py --style style_examples.txt --context context.txt + # or, to auto-pick exemplars from a person's message history instead of + # a hand-curated style file (retrieve_style.py, tech-design.md §2-1): + python3 generate_draft.py --history history.txt --context context.txt style_examples.txt: one example message per line, written by the target person. context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending with the incoming message that needs a reply. +history.txt: the person's past messages, one per line, oldest first. """ import argparse import os @@ -24,6 +28,7 @@ import sys from pathlib import Path from escalation_filter import check as check_escalation +from retrieve_style import retrieve as retrieve_style_examples def load_dotenv_if_present(): @@ -73,45 +78,65 @@ def last_incoming_text(context_lines): return last.split(": ", 1)[1] if ": " in last else last -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") - ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") - ap.add_argument("--model", default="gemini-2.5-flash") - args = ap.parse_args() - - load_dotenv_if_present() - - with open(args.style, encoding="utf-8") as f: - style_examples = [l.strip() for l in f if l.strip()] - with open(args.context, encoding="utf-8") as f: - context_lines = [l.strip() for l in f if l.strip()] +def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None): + """Returns (status, text). status is one of "escalate" | "no_key" | "ok". + "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). + "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). + "ok": text is the generated draft. + """ 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 + return "escalate", gate.reason - api_key = os.environ.get("GEMINI_API_KEY") + api_key = api_key or os.environ.get("GEMINI_API_KEY") if not api_key: - print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", - file=sys.stderr) - print(build_user_prompt(style_examples, context_lines)) - return + return "no_key", build_user_prompt(style_examples, context_lines) from google import genai # pip install google-genai from google.genai import types client = genai.Client(api_key=api_key) resp = client.models.generate_content( - model=args.model, + model=model, contents=build_user_prompt(style_examples, context_lines), config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300), ) - print(resp.text.strip()) + return "ok", resp.text.strip() + + +def main(): + ap = argparse.ArgumentParser() + style_group = ap.add_mutually_exclusive_group(required=True) + style_group.add_argument("--style", help="말투 예시 파일 (한 줄에 한 문장, 직접 큐레이션)") + style_group.add_argument("--history", help="과거 발화 전체 파일 (한 줄에 하나) -- 맥락과 비슷한 것을 자동 검색") + ap.add_argument("--k", type=int, default=6, help="--history 사용 시 검색해올 예시 개수") + ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") + ap.add_argument("--model", default="gemini-2.5-flash") + args = ap.parse_args() + + load_dotenv_if_present() + + with open(args.context, encoding="utf-8") as f: + context_lines = [l.strip() for l in f if l.strip()] + + if args.style: + with open(args.style, encoding="utf-8") as f: + style_examples = [l.strip() for l in f if l.strip()] + else: + with open(args.history, encoding="utf-8") as f: + history = [l.strip() for l in f if l.strip()] + style_examples = retrieve_style_examples(history, last_incoming_text(context_lines), k=args.k) + + status, text = draft_reply(style_examples, context_lines, model=args.model) + if status == "escalate": + print(f"[ESCALATE:{text}] 이 내용은 본인 확인이 필요합니다.") + elif status == "no_key": + print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", + file=sys.stderr) + print(text) + else: + print(text) if __name__ == "__main__": diff --git a/poc/tone-corpus/retrieve_style.py b/poc/tone-corpus/retrieve_style.py new file mode 100644 index 0000000..0af89da --- /dev/null +++ b/poc/tone-corpus/retrieve_style.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Keyword/recency-based retrieval -- the search step of the personalization +layer in `docs/tech-design.md` §2-1: "지금부터 임베딩 인프라를 먼저 만들지 +않는다" -- so this is plain token overlap + recency, not embeddings. + +Given a person's message history (one message per line, oldest first) and +the incoming message they need to reply to, returns the top-k past messages +most likely to show the relevant tone -- meant to feed `generate_draft.py`'s +--style input instead of a hand-curated file. + +Usage: + python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6 +""" +import argparse +import re + +TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]+") + + +def tokenize(text): + return set(TOKEN_RE.findall(text)) + + +def _jaccard(a, b): + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def retrieve(history_messages, query_text, k=6): + """history_messages: oldest-first list of past messages by the person. + Ranked by keyword overlap with query_text first; recency only breaks + ties. A weighted-sum of the two was tried and dropped -- Korean short + messages rarely share more than one or two tokens (no particle + stripping here, so "핀란드" and "핀란드는" don't match each other), + so any nonzero recency weight ended up drowning out real overlap and + just returning the most recent messages regardless of topic.""" + query_tokens = tokenize(query_text) + n = len(history_messages) + scored = [] + for i, msg in enumerate(history_messages): + overlap = _jaccard(tokenize(msg), query_tokens) + recency = i / max(n - 1, 1) + scored.append(((overlap, recency), msg)) + scored.sort(key=lambda pair: pair[0], reverse=True) + return [msg for _, msg in scored[:k]] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--history", required=True, help="과거 발화 파일 (한 줄에 하나, 오래된 것부터)") + ap.add_argument("--query", required=True, help="지금 답장해야 할 상대 메시지") + ap.add_argument("--k", type=int, default=6) + args = ap.parse_args() + + with open(args.history, encoding="utf-8") as f: + history = [l.strip() for l in f if l.strip()] + + for msg in retrieve(history, args.query, k=args.k): + print(msg) + + +if __name__ == "__main__": + main() From 3b595587ccf9920e736fb7dfc39ce907023ba087 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:48:19 +0000 Subject: [PATCH 18/33] Break Phase 1 (actual app build) into a concrete workstream plan PoC execution is on hold for now, so this splits Phase 1 into work that's independent of PoC results (backend infra, client shell, AI pipeline productionization) vs. values that genuinely need PoC data (autonomy defaults, whitelist topics, trust UX copy) -- so infrastructure work isn't blocked while PoC recruiting catches up. --- docs/PLANNING.md | 4 +++ docs/roadmap.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 099b887..66f15bf 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -150,3 +150,7 @@ - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) - [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md` - [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 +- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과 + 무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠 +- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md` + Phase 1 §1, 회의 필요 diff --git a/docs/roadmap.md b/docs/roadmap.md index 0183703..6936a68 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,6 +15,75 @@ - 안드로이드 우선, 초대 기반 소규모 베타 (지인 네트워크) - 게이트: `vision.md` 성공 지표(자연스러움 70%, 거부율 10% 미만, 안전선 위반 0건) 충족 여부 +### Phase 1 상세 작업 분해 + +원칙상 Phase 0(PoC) 검증 후 착수하는 게 맞지만, PoC 실제 실행(참가자 모집)이 보류된 지금 +**PoC 결과와 무관한 기반 작업은 병행 착수**하고, **PoC 결과가 있어야 정할 수 있는 세부 값**은 +자리만 비워두고 나중에 채우는 방식으로 진행한다. 아래 §3이 그 경계선이다. + +#### 1. 착수 전 확정 필요 (기술 스택) + +- 클라이언트: Android 네이티브(Kotlin) vs 크로스플랫폼 — 미정 +- 백엔드 언어/프레임워크, 데이터베이스 — 미정 +- 메시지 릴레이 방식 (자체 서버 WebSocket vs 관리형 서비스) — 미정 +- 온디바이스 저장소/암호화 방식 — 미정 +- Gemini API 프로덕션 키·쿼터 관리 — 미정 (`poc/tone-corpus/`는 PoC용 키만 다룸) + +#### 2. 워크스트림별 작업 + +**2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) +- 계정/인증 (초대 코드 기반 가입) +- 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) +- DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules +- 푸시 알림 서비스 연동 + +**2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) +- `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 백엔드 API로 이식 +- 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 + (`tech-design.md` §3 흐름 그대로) +- 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 +- 사후 알림 + 되돌리기 로그 스키마/API + +**2.3 안드로이드 클라이언트** +- 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 +- 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 +- 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 +- 자율성 설정 화면(L0~L2, 화이트리스트, 상대별 예외) — 무관, 착수 가능 +- 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 + +**2.4 안전장치 통합** (전 구간 필수, 타협 불가) +- 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 +- 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 + +**2.5 QA/테스트** +- `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게 +- 자율성 플로우(L0→L1→L2) 통합 테스트 +- 온보딩·채팅·설정 수동 QA + +**2.6 베타 배포 준비** +- 초대 기반 베타 가입 플로우 +- `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 +- 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) + +#### 3. PoC 결과가 있어야 정할 수 있는 것 (병행 불가, 값만 비워둠) + +- 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 +- 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 +- 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요 +- 실제 베타 오픈 시점 — `vision.md` 게이트 통과 필요 + +#### 4. 권장 착수 순서 + +1. §1 기술 스택 결정 (회의 필요) +2. 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) +3. 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) +4. 2.3 나머지 UX(온보딩·설정·뱃지) +5. 2.4/2.5 안전장치·QA +6. PoC 결과 반영 → §3 확정 → 2.6 베타 오픈 + +1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 +§3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. + ## Phase 2 — L3 확장 + 베타 확대 - 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만 From 94479a51d60745d22bb8e7d518f1a21440b34bb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:54:52 +0000 Subject: [PATCH 19/33] Decide Phase 1 tech stack and turn the plan into a working checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tech-design.md §8 settles the stack (Android/Kotlin, Python/FastAPI, PostgreSQL, WebSocket relay, Room+SQLCipher) so it stops blocking item 1 of the build order. roadmap.md's Phase 1 breakdown is now checkboxes instead of prose, and AGENTS.md adds the rule to check/ update that checklist before and after any Phase 1 app-build task, rather than tracking progress ad hoc. --- AGENTS.md | 32 +++++++++++++------ CLAUDE.md | 3 +- docs/roadmap.md | 75 +++++++++++++++++++++++---------------------- docs/tech-design.md | 17 ++++++++++ 4 files changed, 80 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c9f4513..17f4cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,17 +74,31 @@ These apply at every autonomy level and must not be weakened for convenience: Follow `docs/PLANNING.md`: decide → narrow → validate → specify. -Current next priorities (do not invent a full stack first): +PoC execution (real participant recruiting for PoC #1/#3, Q3 interviews) is +currently on hold — that data collection is deferred, not cancelled. In the +meantime, work has moved into Phase 1 app-build groundwork that doesn't +depend on PoC results (see `docs/roadmap.md` Phase 1 §2 for which workstreams +those are). -1. PoC #1 — on-device tone realism -2. PoC #3 — impersonation / trust acceptance (badge, veto UX) -3. Clickable prototype (badge + veto) -4. Small user interviews -5. Meeting review to promote Q1~Q7 from 제안 → 확정 +The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8 +(Android native/Kotlin, Python/FastAPI backend, PostgreSQL, WebSocket relay, +Room+SQLCipher on-device). Do not re-litigate or invent a different stack; +build within this one unless a decision-log-style update changes it. -Do not invent frameworks, folder layouts, or CI conventions until real code -exists. When code work starts, prefer Android-first changes aligned with -`docs/tech-design.md` and keep L0~L2 + safety invariants intact. +### Phase 1 앱 빌드 작업 규칙 + +- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s + "Phase 1 상세 작업 분해" checklist for what's already done and what's next. +- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order + without a reason, and note the reason in the checklist if you do. +- When a task is finished, check it off in that same checklist. When you + discover a new sub-task, add it there rather than tracking it elsewhere. +- Items under Phase 1 §3 ("PoC 결과가 있어야 정할 수 있는 것") stay unresolved + until real PoC data comes in — don't guess a default to unblock yourself; + leave a placeholder and move on to other checklist items instead. + +Do not invent frameworks, folder layouts, or CI conventions beyond what +`docs/tech-design.md` §8 and `docs/roadmap.md` already specify. ## Documentation conventions diff --git a/CLAUDE.md b/CLAUDE.md index b91be9a..c17c295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md). Quick context: -- This repo is planning docs only (no app code yet). +- This repo started as planning docs only; Phase 1 app-build has now begun. + Check `docs/roadmap.md` Phase 1 checklist before starting app-build work. - Working product name: **분신** (tentative). - Source of working decisions: `docs/decision-log.md` (status: 제안). - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6936a68..af54a6c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,65 +21,66 @@ **PoC 결과와 무관한 기반 작업은 병행 착수**하고, **PoC 결과가 있어야 정할 수 있는 세부 값**은 자리만 비워두고 나중에 채우는 방식으로 진행한다. 아래 §3이 그 경계선이다. +이 체크리스트가 Phase 1 작업의 단일 기준이다 — 작업을 시작하기 전에 여기서 다음 항목을 확인하고, +끝나면 체크하고, 새로 발견한 하위 작업은 해당 항목 밑에 추가한다 (`AGENTS.md` "Phase 1 앱 빌드 +작업 규칙" 참고). + #### 1. 착수 전 확정 필요 (기술 스택) -- 클라이언트: Android 네이티브(Kotlin) vs 크로스플랫폼 — 미정 -- 백엔드 언어/프레임워크, 데이터베이스 — 미정 -- 메시지 릴레이 방식 (자체 서버 WebSocket vs 관리형 서비스) — 미정 -- 온디바이스 저장소/암호화 방식 — 미정 -- Gemini API 프로덕션 키·쿼터 관리 — 미정 (`poc/tone-corpus/`는 PoC용 키만 다룸) +- [x] 기술 스택 결정 — `tech-design.md` §8 (Android 네이티브/Kotlin, 백엔드 Python/FastAPI, + PostgreSQL, WebSocket 릴레이, Room+SQLCipher, Gemini 키 분리) #### 2. 워크스트림별 작업 **2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) -- 계정/인증 (초대 코드 기반 가입) -- 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) -- DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules -- 푸시 알림 서비스 연동 +- [ ] 계정/인증 (초대 코드 기반 가입) +- [ ] 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) +- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules +- [ ] 푸시 알림 서비스 연동 **2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) -- `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 백엔드 API로 이식 -- 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 +- [ ] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 백엔드 API로 이식 +- [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 (`tech-design.md` §3 흐름 그대로) -- 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 -- 사후 알림 + 되돌리기 로그 스키마/API +- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 +- [ ] 사후 알림 + 되돌리기 로그 스키마/API **2.3 안드로이드 클라이언트** -- 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 -- 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 -- 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 -- 자율성 설정 화면(L0~L2, 화이트리스트, 상대별 예외) — 무관, 착수 가능 -- 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 +- [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 +- [ ] 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 +- [ ] 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 +- [ ] 자율성 설정 화면(L0~L2, 화이트리스트, 상대별 예외) — 무관, 착수 가능 +- [ ] 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 **2.4 안전장치 통합** (전 구간 필수, 타협 불가) -- 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 -- 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 +- [ ] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 +- [ ] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 **2.5 QA/테스트** -- `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게 -- 자율성 플로우(L0→L1→L2) 통합 테스트 -- 온보딩·채팅·설정 수동 QA +- [ ] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게 +- [ ] 자율성 플로우(L0→L1→L2) 통합 테스트 +- [ ] 온보딩·채팅·설정 수동 QA **2.6 베타 배포 준비** -- 초대 기반 베타 가입 플로우 -- `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 -- 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) +- [ ] 초대 기반 베타 가입 플로우 +- [ ] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 +- [ ] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) #### 3. PoC 결과가 있어야 정할 수 있는 것 (병행 불가, 값만 비워둠) -- 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 -- 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 -- 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요 -- 실제 베타 오픈 시점 — `vision.md` 게이트 통과 필요 +- [ ] 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 +- [ ] 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 +- [ ] 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요 +- [ ] 실제 베타 오픈 시점 — `vision.md` 게이트 통과 필요 -#### 4. 권장 착수 순서 +#### 4. 권장 착수 순서 (진행 상황) -1. §1 기술 스택 결정 (회의 필요) -2. 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) -3. 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) -4. 2.3 나머지 UX(온보딩·설정·뱃지) -5. 2.4/2.5 안전장치·QA -6. PoC 결과 반영 → §3 확정 → 2.6 베타 오픈 +1. [x] §1 기술 스택 결정 +2. [ ] 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) — **다음 작업** +3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) +4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) +5. [ ] 2.4/2.5 안전장치·QA +6. [ ] PoC 결과 반영 → §3 확정 → 2.6 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 §3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. diff --git a/docs/tech-design.md b/docs/tech-design.md index 265404b..2e1673a 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -103,3 +103,20 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다. - 분신 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음. - 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충. + +## 8. 기술 스택 결정 (Phase 1) + +`roadmap.md` Phase 1 §1의 "착수 전 확정 필요" 항목에 대한 결정. PoC 데이터와 무관하게 지금 +확정할 수 있는 것들이라 여기서 정리한다 — 자율성 기본값 같은 PoC 의존 값은 여전히 미정으로 남는다. + +| 항목 | 결정 | 근거 | +|---|---|---| +| 클라이언트 | Android 네이티브 (Kotlin, Jetpack Compose) | Q7이 이미 안드로이드 우선을 확정함. 크로스플랫폼은 v2 OS 레이어(알림 접근 권한 API)에서 결국 네이티브가 필요해지므로, 처음부터 네이티브로 시작하면 나중에 다시 만들 일이 없음 | +| 백엔드 | Python (FastAPI) | `poc/tone-corpus/`의 AI 파이프라인(generate_draft·escalation_filter·retrieve_style)이 이미 Python — 언어를 바꾸면 그대로 재사용 못 하고 다시 짜야 함. 클로즈드 베타 규모에서 성능은 병목이 아님 | +| 메시지 릴레이 | FastAPI WebSocket | 자체 서버로 충분한 규모(소규모 지인 네트워크 베타). Kafka·관리형 pub-sub 같은 건 지금 시점에 과한 인프라 | +| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합, 운영 경험 풍부 | +| 온디바이스 저장소 | Android Room (SQLite) + SQLCipher 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | +| Gemini API 키 관리 | 프로덕션 키는 서버 환경변수/시크릿 매니저로, PoC 키와 분리 | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 트래픽과 쿼터를 섞지 않음 | + +이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 +남아있는 PoC 의존 항목이다 — 여기서 같이 정하지 않는다. From 9a5358d0b10878ad7d94ec8b3efbd8596046b75c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:57:57 +0000 Subject: [PATCH 20/33] Start Phase 1 backend skeleton (item 2 of the build order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI app with invite-code signup, message send/relay over WebSocket, and the DB schema from roadmap.md Phase 1 §2.1 (users, contacts, conversations, messages, twin_settings, whitelist_rules, escalation_logs). Defaults to SQLite for local dev, PostgreSQL in prod per tech-design.md §8. Verified end-to-end with TestClient: signup, duplicate-invite-code rejection (409), message persistence, 404 on an unknown conversation, and WebSocket broadcast delivery all behave as expected. Push notifications and the AI pipeline integration (item 3) are not in this commit -- see backend/README.md and roadmap.md's checklist. --- .gitignore | 3 + backend/README.md | 39 ++++++++++++ backend/app/__init__.py | 0 backend/app/config.py | 8 +++ backend/app/db.py | 17 ++++++ backend/app/main.py | 103 +++++++++++++++++++++++++++++++ backend/app/models.py | 128 +++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 6 ++ docs/roadmap.md | 11 ++-- 9 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/requirements.txt diff --git a/.gitignore b/.gitignore index 37de000..7152287 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ poc/tone-corpus/data/ __pycache__/ *.pyc + +# Local dev DB (backend/, SQLite fallback for DATABASE_URL) +*.db diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..00e89f2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# 분신 backend + +Phase 1 백엔드 인프라 뼈대 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 +`docs/tech-design.md` §8 참고 — Python/FastAPI, PostgreSQL(프로덕션)/SQLite(로컬 개발), +WebSocket 릴레이. + +## 실행 + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +기본은 `sqlite:///./dev.db`로 뜬다. 프로덕션 DB를 쓰려면: + +```bash +export DATABASE_URL=postgresql+psycopg2://user:pass@host/dbname +``` + +## 지금 있는 것 (2.1 백엔드 인프라 뼈대) + +- `GET /health` — 헬스체크 +- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409) +- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트 +- `WS /ws/conversations/{id}` — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) +- DB 모델 (`app/models.py`): `users`, `contacts`, `conversations`, + `conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`, + `escalation_logs` — `roadmap.md` Phase 1 §2.1 스키마 그대로 + +signup/message-send/404/WebSocket 브로드캐스트까지 `TestClient`로 실제 실행해서 확인함 +(테스트 스크립트는 커밋 안 함 — 필요하면 정식 `tests/`로 다시 만들 것). + +## 아직 없는 것 (다음 워크스트림) + +- 2.2 AI 파이프라인 연동 — `poc/tone-corpus/generate_draft.py` 등을 여기 API로 이식 +- 푸시 알림 연동 +- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음) +- 프로덕션 마이그레이션 도구 (지금은 `Base.metadata.create_all`로 스타트업 시 테이블 생성 — + Alembic 같은 마이그레이션은 스키마가 안정되면 도입) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..25296fb --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,8 @@ +import os + +# Production: postgresql+psycopg2://user:pass@host/dbname (tech-design.md §8). +# Defaults to a local SQLite file so the app runs without a Postgres instance +# during development/testing. +DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./dev.db") + +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..b4cc0e2 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,17 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from .config import DATABASE_URL + +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +engine = create_engine(DATABASE_URL, connect_args=connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..010b31a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,103 @@ +from typing import Dict, List + +from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from . import models +from .db import Base, engine, get_db + +app = FastAPI(title="분신 backend") + + +@app.on_event("startup") +def create_tables(): + Base.metadata.create_all(bind=engine) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +class SignupRequest(BaseModel): + invite_code: str + display_name: str + + +@app.post("/auth/signup") +def signup(req: SignupRequest, db: Session = Depends(get_db)): + existing = db.query(models.User).filter_by(invite_code=req.invite_code).first() + if existing: + raise HTTPException(status_code=409, detail="invite_code already used") + user = models.User(invite_code=req.invite_code, display_name=req.display_name) + db.add(user) + db.commit() + db.refresh(user) + db.add(models.TwinSettings(user_id=user.id)) + db.commit() + return {"id": user.id, "display_name": user.display_name} + + +class SendMessageRequest(BaseModel): + sender_id: int + text: str + sender_mode: models.SenderMode = models.SenderMode.HUMAN + + +@app.post("/conversations/{conversation_id}/messages") +async def send_message(conversation_id: int, req: SendMessageRequest, db: Session = Depends(get_db)): + conversation = db.query(models.Conversation).get(conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="conversation not found") + message = models.Message( + conversation_id=conversation_id, + sender_id=req.sender_id, + sender_mode=req.sender_mode, + text=req.text, + ) + db.add(message) + db.commit() + db.refresh(message) + await relay.broadcast(conversation_id, { + "id": message.id, + "sender_id": message.sender_id, + "sender_mode": message.sender_mode.value, + "text": message.text, + }) + return {"id": message.id} + + +class ConnectionManager: + """In-memory WebSocket fan-out per conversation. Fine for a small closed + beta (roadmap.md Phase 1); revisit if the relay needs to scale past one + process.""" + + def __init__(self): + self.connections: Dict[int, List[WebSocket]] = {} + + async def connect(self, conversation_id: int, websocket: WebSocket): + await websocket.accept() + self.connections.setdefault(conversation_id, []).append(websocket) + + def disconnect(self, conversation_id: int, websocket: WebSocket): + conns = self.connections.get(conversation_id, []) + if websocket in conns: + conns.remove(websocket) + + async def broadcast(self, conversation_id: int, payload: dict): + for ws in self.connections.get(conversation_id, []): + await ws.send_json(payload) + + +relay = ConnectionManager() + + +@app.websocket("/ws/conversations/{conversation_id}") +async def conversation_socket(websocket: WebSocket, conversation_id: int): + await relay.connect(conversation_id, websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + relay.disconnect(conversation_id, websocket) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..361d7c4 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,128 @@ +import enum + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + func, +) +from sqlalchemy.orm import relationship + +from .db import Base + + +class AutonomyLevel(str, enum.Enum): + L0 = "L0" + L1 = "L1" + L2 = "L2" + + +class SenderMode(str, enum.Enum): + HUMAN = "human" + TWIN = "twin" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + invite_code = Column(String, unique=True, nullable=False, index=True) + display_name = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + twin_settings = relationship("TwinSettings", back_populates="user", uselist=False) + + +class Contact(Base): + """A relationship between the owner and a counterpart -- carries + per-peer state like the veto flag (tech-design.md §4: + twin_disabled_by_peer), independent of any one conversation.""" + + __tablename__ = "contacts" + + id = Column(Integer, primary_key=True) + owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + contact_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + display_name = Column(String, nullable=False) + relationship_note = Column(String, nullable=True) + twin_disabled_by_peer = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(Integer, primary_key=True) + is_group = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + participants = relationship("ConversationParticipant", back_populates="conversation") + messages = relationship("Message", back_populates="conversation") + + +class ConversationParticipant(Base): + __tablename__ = "conversation_participants" + + id = Column(Integer, primary_key=True) + conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + conversation = relationship("Conversation", back_populates="participants") + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True) + conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + sender_mode = Column(Enum(SenderMode), nullable=False, default=SenderMode.HUMAN) + text = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + conversation = relationship("Conversation", back_populates="messages") + + +class TwinSettings(Base): + __tablename__ = "twin_settings" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) + autonomy_level = Column(Enum(AutonomyLevel), nullable=False, default=AutonomyLevel.L0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="twin_settings") + + +class WhitelistRule(Base): + """L2 auto-send whitelist -- a (contact, topic) pair the owner has + approved for unattended replies. contact_id null = applies to any + counterpart (PRD.md §3.1).""" + + __tablename__ = "whitelist_rules" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True) + topic_keyword = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class EscalationLog(Base): + """One row per escalation_filter.py trigger -- the post-hoc notification + + undo trail required by AGENTS.md's absolute safety invariants.""" + + __tablename__ = "escalation_logs" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) + reason = Column(String, nullable=False) + message_snippet = Column(String, nullable=False) + resolved = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..be191dd --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +psycopg2-binary>=2.9.0 +websockets>=13.0 +pydantic>=2.0.0 diff --git a/docs/roadmap.md b/docs/roadmap.md index af54a6c..e4654d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,9 +33,11 @@ #### 2. 워크스트림별 작업 **2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) -- [ ] 계정/인증 (초대 코드 기반 가입) -- [ ] 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) -- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules +- [x] 계정/인증 (초대 코드 기반 가입) — `backend/app/main.py` `POST /auth/signup`, 중복 코드 409 확인함 +- [x] 메시지 릴레이 서버 (송수신) — `backend/app/main.py` WebSocket + REST, 실제 TestClient로 브로드캐스트 확인함. + 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 +- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules + — `backend/app/models.py` - [ ] 푸시 알림 서비스 연동 **2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) @@ -76,7 +78,8 @@ #### 4. 권장 착수 순서 (진행 상황) 1. [x] §1 기술 스택 결정 -2. [ ] 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) — **다음 작업** +2. [~] 2.1 백엔드 기본 인프라 (계정/인증, DB 스키마, 메시지 릴레이 — `backend/` 완료, 푸시 알림만 남음) + + 2.3 채팅 UI 뼈대 (병행) — **안드로이드 클라이언트 쪽이 다음 작업** 3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA From a1777516cf0269d93442f2e859cac4590d7f73af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:12:51 +0000 Subject: [PATCH 21/33] Switch client stack from Android/Kotlin to Flutter/Dart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native wasn't actually required for the v2 OS-layer notification listener -- Flutter reaches it via platform channels like any other native Android API, same pattern many production apps already use. Flutter's faster iteration on the chat UI (already validated in the click prototype) matters more for v1 than starting native. Updates tech-design.md §8, roadmap.md's checklist, and AGENTS.md accordingly. --- AGENTS.md | 4 ++-- docs/roadmap.md | 6 +++--- docs/tech-design.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17f4cf9..dd0be71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,8 +81,8 @@ depend on PoC results (see `docs/roadmap.md` Phase 1 §2 for which workstreams those are). The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8 -(Android native/Kotlin, Python/FastAPI backend, PostgreSQL, WebSocket relay, -Room+SQLCipher on-device). Do not re-litigate or invent a different stack; +(Flutter/Dart client, Python/FastAPI backend, PostgreSQL, WebSocket relay, +drift+SQLCipher on-device). Do not re-litigate or invent a different stack; build within this one unless a decision-log-style update changes it. ### Phase 1 앱 빌드 작업 규칙 diff --git a/docs/roadmap.md b/docs/roadmap.md index e4654d6..4bee807 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,8 +27,8 @@ #### 1. 착수 전 확정 필요 (기술 스택) -- [x] 기술 스택 결정 — `tech-design.md` §8 (Android 네이티브/Kotlin, 백엔드 Python/FastAPI, - PostgreSQL, WebSocket 릴레이, Room+SQLCipher, Gemini 키 분리) +- [x] 기술 스택 결정 — `tech-design.md` §8 (Flutter/Dart, 백엔드 Python/FastAPI, + PostgreSQL, WebSocket 릴레이, drift+SQLCipher, Gemini 키 분리) #### 2. 워크스트림별 작업 @@ -47,7 +47,7 @@ - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 - [ ] 사후 알림 + 되돌리기 로그 스키마/API -**2.3 안드로이드 클라이언트** +**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** - [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 - [ ] 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 - [ ] 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 diff --git a/docs/tech-design.md b/docs/tech-design.md index 2e1673a..1410fc1 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -111,11 +111,11 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 | 항목 | 결정 | 근거 | |---|---|---| -| 클라이언트 | Android 네이티브 (Kotlin, Jetpack Compose) | Q7이 이미 안드로이드 우선을 확정함. 크로스플랫폼은 v2 OS 레이어(알림 접근 권한 API)에서 결국 네이티브가 필요해지므로, 처음부터 네이티브로 시작하면 나중에 다시 만들 일이 없음 | +| 클라이언트 | Flutter (Dart) | Q7이 안드로이드 우선을 확정했지만 네이티브를 강제하진 않음. 채팅 UI(이미 클릭 프로토타입으로 검증된 디자인)를 핫리로드로 빠르게 만들 수 있어 v1 개발 속도에 유리. v2 OS 레이어의 알림 접근 권한(NotificationListenerService)은 platform channel로 네이티브 Android 모듈을 붙여 해결 — 클라이언트 전체를 네이티브로 갈 필요는 없음 | | 백엔드 | Python (FastAPI) | `poc/tone-corpus/`의 AI 파이프라인(generate_draft·escalation_filter·retrieve_style)이 이미 Python — 언어를 바꾸면 그대로 재사용 못 하고 다시 짜야 함. 클로즈드 베타 규모에서 성능은 병목이 아님 | | 메시지 릴레이 | FastAPI WebSocket | 자체 서버로 충분한 규모(소규모 지인 네트워크 베타). Kafka·관리형 pub-sub 같은 건 지금 시점에 과한 인프라 | | 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합, 운영 경험 풍부 | -| 온디바이스 저장소 | Android Room (SQLite) + SQLCipher 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | +| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | | Gemini API 키 관리 | 프로덕션 키는 서버 환경변수/시크릿 매니저로, PoC 키와 분리 | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 트래픽과 쿼터를 섞지 않음 | 이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 From 874e96566513d0d5caae11dbfe7f7d023301178c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:21:35 +0000 Subject: [PATCH 22/33] Split backend into a Go core service + Python AI service Reconsidered pure Go (would mean reimplementing and re-verifying the already-tested AI pipeline) and pure Python (leaves perceived performance/concurrency headroom on the table for a preemptive scale bet). Landed on: Go handles auth/messaging/DB, Python keeps owning generate_draft/escalation_filter/retrieve_style behind an internal API. backend/ (Python) is now labeled a verified prototype for the Go rewrite to match, not the final service. --- AGENTS.md | 7 ++++--- backend/README.md | 10 ++++++---- docs/roadmap.md | 33 ++++++++++++++++++--------------- docs/tech-design.md | 18 ++++++++++++++---- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd0be71..7c3dd62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,9 +81,10 @@ depend on PoC results (see `docs/roadmap.md` Phase 1 §2 for which workstreams those are). The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8 -(Flutter/Dart client, Python/FastAPI backend, PostgreSQL, WebSocket relay, -drift+SQLCipher on-device). Do not re-litigate or invent a different stack; -build within this one unless a decision-log-style update changes it. +(Flutter/Dart client, Go core backend + Python AI service, PostgreSQL, +WebSocket relay, drift+SQLCipher on-device). Do not re-litigate or invent a +different stack; build within this one unless a decision-log-style update +changes it. ### Phase 1 앱 빌드 작업 규칙 diff --git a/backend/README.md b/backend/README.md index 00e89f2..08de9b1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,10 @@ -# 분신 backend +# 분신 backend (Python 프로토타입 — 참고용, Go로 포팅 예정) -Phase 1 백엔드 인프라 뼈대 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 -`docs/tech-design.md` §8 참고 — Python/FastAPI, PostgreSQL(프로덕션)/SQLite(로컬 개발), -WebSocket 릴레이. +**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다 +(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로, +인증·메시지 릴레이·DB 스키마가 실제로 동작하는 걸 검증하는 용도로는 여전히 유효하다 — +**API 설계·DB 스키마 참고용으로 남겨두고, Go 코어 백엔드를 새로 만들 때 이 동작을 그대로 재현한다.** +AI 서비스(2.2)는 그대로 Python으로 간다 — 그건 이 디렉토리가 아니라 별도 서비스로 만들 것. ## 실행 diff --git a/docs/roadmap.md b/docs/roadmap.md index 4bee807..f88e2a7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,23 +27,26 @@ #### 1. 착수 전 확정 필요 (기술 스택) -- [x] 기술 스택 결정 — `tech-design.md` §8 (Flutter/Dart, 백엔드 Python/FastAPI, - PostgreSQL, WebSocket 릴레이, drift+SQLCipher, Gemini 키 분리) +- [x] 기술 스택 결정 — `tech-design.md` §8 (Flutter/Dart 클라이언트, Go 코어 백엔드 + + Python AI 서비스 투트랙, PostgreSQL, WebSocket 릴레이, drift+SQLCipher, Gemini 키 분리) #### 2. 워크스트림별 작업 -**2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) -- [x] 계정/인증 (초대 코드 기반 가입) — `backend/app/main.py` `POST /auth/signup`, 중복 코드 409 확인함 -- [x] 메시지 릴레이 서버 (송수신) — `backend/app/main.py` WebSocket + REST, 실제 TestClient로 브로드캐스트 확인함. - 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 -- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules - — `backend/app/models.py` +**2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능) +- [ ] 계정/인증 (초대 코드 기반 가입) — Python(`backend/app/main.py`)으로 프로토타입 구현·검증 + 완료(중복 코드 409 등), **Go로 포팅 필요** (스택 결정이 Python→Go로 바뀜) +- [ ] 메시지 릴레이 서버 (송수신) — 마찬가지로 Python 프로토타입은 WebSocket 브로드캐스트까지 + 검증됨, **Go(`gorilla/websocket` 등)로 포팅 필요**. 멀티 디바이스 동기화는 프로토타입에도 아직 없음 +- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules + — 스키마 설계 자체는 `backend/app/models.py`(SQLAlchemy)로 확정됨, Go 쪽 ORM(`pgx`/GORM)으로 재작성 - [ ] 푸시 알림 서비스 연동 -**2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) -- [ ] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 백엔드 API로 이식 +**2.2 AI 서비스** (Python, PoC 스크립트 → 내부 API로 승격) +- [ ] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 + FastAPI 서비스로 승격 (Go 코어가 내부망 HTTP로 호출) - [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 - (`tech-design.md` §3 흐름 그대로) + (`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디 + 책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음) - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 - [ ] 사후 알림 + 되돌리기 로그 스키마/API @@ -77,10 +80,10 @@ #### 4. 권장 착수 순서 (진행 상황) -1. [x] §1 기술 스택 결정 -2. [~] 2.1 백엔드 기본 인프라 (계정/인증, DB 스키마, 메시지 릴레이 — `backend/` 완료, 푸시 알림만 남음) - + 2.3 채팅 UI 뼈대 (병행) — **안드로이드 클라이언트 쪽이 다음 작업** -3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) +1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — + 설계 참고용으로 남기고 Go로 포팅 필요) +2. [ ] 2.1 코어 백엔드를 Go로 (신규 구현) + 2.3 Flutter 채팅 UI 뼈대 (병행) — **다음 작업** +3. [ ] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA 6. [ ] PoC 결과 반영 → §3 확정 → 2.6 베타 오픈 diff --git a/docs/tech-design.md b/docs/tech-design.md index 1410fc1..f03d875 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -109,14 +109,24 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 `roadmap.md` Phase 1 §1의 "착수 전 확정 필요" 항목에 대한 결정. PoC 데이터와 무관하게 지금 확정할 수 있는 것들이라 여기서 정리한다 — 자율성 기본값 같은 PoC 의존 값은 여전히 미정으로 남는다. +백엔드는 단일 서비스가 아니라 **두 개로 나눈다** — 코어(인증·메시지·DB)는 Go, AI 파이프라인은 +Python. 순수 성능/동시성만 보면 이 프로젝트 규모(소규모 지인 네트워크 베타)에서 Python +비동기(FastAPI/asyncio)로도 충분하다 — 메신저 릴레이는 CPU-bound가 아니라 I/O-bound라 GIL이 +병목이 되지 않고, 실제 지연은 백엔드 언어가 아니라 Gemini API 왕복 시간이 좌우한다. 그럼에도 +Go를 코어에 쓰기로 한 건 향후 스케일 대비 선제적 판단이며, `poc/tone-corpus/`의 이미 검증된 +AI 파이프라인(generate_draft·escalation_filter·retrieve_style)을 다시 짜지 않기 위해 AI 쪽만 +Python으로 남긴다. + | 항목 | 결정 | 근거 | |---|---|---| | 클라이언트 | Flutter (Dart) | Q7이 안드로이드 우선을 확정했지만 네이티브를 강제하진 않음. 채팅 UI(이미 클릭 프로토타입으로 검증된 디자인)를 핫리로드로 빠르게 만들 수 있어 v1 개발 속도에 유리. v2 OS 레이어의 알림 접근 권한(NotificationListenerService)은 platform channel로 네이티브 Android 모듈을 붙여 해결 — 클라이언트 전체를 네이티브로 갈 필요는 없음 | -| 백엔드 | Python (FastAPI) | `poc/tone-corpus/`의 AI 파이프라인(generate_draft·escalation_filter·retrieve_style)이 이미 Python — 언어를 바꾸면 그대로 재사용 못 하고 다시 짜야 함. 클로즈드 베타 규모에서 성능은 병목이 아님 | -| 메시지 릴레이 | FastAPI WebSocket | 자체 서버로 충분한 규모(소규모 지인 네트워크 베타). Kafka·관리형 pub-sub 같은 건 지금 시점에 과한 인프라 | -| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합, 운영 경험 풍부 | +| 백엔드 — 코어 서비스 | Go (Gin/Echo + `gorilla/websocket`) | 인증, 메시지 릴레이, DB 접근. 동시성·성능 이점, 향후 스케일 대비. 이 프로젝트 규모에선 Python으로도 충분했지만 선제적으로 Go 선택 | +| 백엔드 — AI 서비스 | Python (FastAPI) | `generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 그대로 감싸는 내부 API. 이미 실행 검증까지 끝난 코드를 다시 짜지 않기 위함 | +| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 | +| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 | +| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM | | 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | -| Gemini API 키 관리 | 프로덕션 키는 서버 환경변수/시크릿 매니저로, PoC 키와 분리 | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 트래픽과 쿼터를 섞지 않음 | +| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 | 이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 남아있는 PoC 의존 항목이다 — 여기서 같이 정하지 않는다. From 9cd86a4da5fd90487768fa430daf2df546387bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:34:01 +0000 Subject: [PATCH 23/33] Pin down why v1 is Android-only now that the client is Flutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter weakens the old "single platform = faster dev" argument for skipping iOS, so tech-design.md §6/§8 now state the reasons that still hold: v2's OS-layer notification access is Android-only by Apple's own policy regardless of framework, and the dev environment is Windows, so an iOS build isn't even possible right now. Flutter still means no UI rewrite once a Mac is available later. --- docs/tech-design.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/tech-design.md b/docs/tech-design.md index f03d875..3e3ce4a 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -92,10 +92,15 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 ## 6. 플랫폼 전략 -- **Android 우선.** 알림 접근 권한 기반 확장(v2 OS 레이어)을 염두에 둔 선택이기도 하고, v1 - 자체 앱 단계에서도 베타 규모라면 단일 플랫폼으로 좁혀 개발 속도를 확보하는 게 유리하다. -- iOS는 베타 반응 확인 후 병행 착수. (근거: `decision-log.md` Q7, 원본 회의자료 §oslayer 리스크 - "iOS는 안드로이드보다 훨씬 제한적") +- **Android 우선 (v1은 Android만).** 근거 세 가지: + 1. v2 OS 레이어(알림 접근 권한) 확장이 애초에 안드로이드에서만 가능함 — 애플이 다른 앱의 + 알림 내용을 읽는 것 자체를 정책적으로 막고 있어, 클라이언트 프레임워크와 무관하게 구조적으로 + 안드로이드 전용인 기능임 + 2. 개발 환경이 Windows라 iOS 빌드(Xcode/macOS 필요)를 할 수 없음 — v1 시점의 실질적 제약 + 3. Flutter라 iOS 코드 재작성 비용 자체는 낮지만, 위 두 이유로 지금은 굳이 열 이유가 없음 +- iOS는 빌드 가능한 환경(Mac)이 갖춰지거나 베타 반응을 보고 병행 착수 여부를 다시 판단한다. + Flutter를 쓰므로 그때 가서 UI를 다시 만들 필요는 없고, iOS 빌드·서명·APNs 설정 등 플랫폼별 + 작업만 추가하면 된다. ## 7. v1에서 의도적으로 안 만드는 것 @@ -119,7 +124,7 @@ Python으로 남긴다. | 항목 | 결정 | 근거 | |---|---|---| -| 클라이언트 | Flutter (Dart) | Q7이 안드로이드 우선을 확정했지만 네이티브를 강제하진 않음. 채팅 UI(이미 클릭 프로토타입으로 검증된 디자인)를 핫리로드로 빠르게 만들 수 있어 v1 개발 속도에 유리. v2 OS 레이어의 알림 접근 권한(NotificationListenerService)은 platform channel로 네이티브 Android 모듈을 붙여 해결 — 클라이언트 전체를 네이티브로 갈 필요는 없음 | +| 클라이언트 | Flutter (Dart), **v1은 Android 빌드만** | Q7이 안드로이드 우선을 확정했지만 네이티브를 강제하진 않음. 채팅 UI(이미 클릭 프로토타입으로 검증된 디자인)를 핫리로드로 빠르게 만들 수 있어 v1 개발 속도에 유리. v2 OS 레이어의 알림 접근 권한(NotificationListenerService)은 platform channel로 네이티브 Android 모듈을 붙여 해결 — 클라이언트 전체를 네이티브로 갈 필요는 없음. iOS는 개발 환경이 Windows라 지금은 빌드 자체가 안 됨 (§6 참고) — Flutter라 나중에 Mac 환경이 생기면 UI 재작성 없이 iOS 빌드만 추가하면 됨 | | 백엔드 — 코어 서비스 | Go (Gin/Echo + `gorilla/websocket`) | 인증, 메시지 릴레이, DB 접근. 동시성·성능 이점, 향후 스케일 대비. 이 프로젝트 규모에선 Python으로도 충분했지만 선제적으로 Go 선택 | | 백엔드 — AI 서비스 | Python (FastAPI) | `generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 그대로 감싸는 내부 API. 이미 실행 검증까지 끝난 코드를 다시 짜지 않기 위함 | | 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 | From 87b9bf3505a313bd69e150c0f65bde576406ebab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:37:24 +0000 Subject: [PATCH 24/33] Make explicit: PoC-dependent items wait until everything else is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build order already ended with the PoC-gated section, but it wasn't stated as a hard rule. Now roadmap.md and AGENTS.md both say not to touch Phase 1 §3 until items 1-5 of the build order are fully finished, even if PoC data happens to land earlier -- no jumping the queue to fill in a value early. --- AGENTS.md | 4 +++- docs/roadmap.md | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c3dd62..8445c4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,9 @@ changes it. discover a new sub-task, add it there rather than tracking it elsewhere. - Items under Phase 1 §3 ("PoC 결과가 있어야 정할 수 있는 것") stay unresolved until real PoC data comes in — don't guess a default to unblock yourself; - leave a placeholder and move on to other checklist items instead. + leave a placeholder and move on to other checklist items instead. Do not + start §3 early even if PoC data happens to arrive mid-way — finish all of + §4's items 1-5 (the rest of the build order) first, then come back to §3. Do not invent frameworks, folder layouts, or CI conventions beyond what `docs/tech-design.md` §8 and `docs/roadmap.md` already specify. diff --git a/docs/roadmap.md b/docs/roadmap.md index f88e2a7..08af1a5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,7 +71,11 @@ - [ ] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 - [ ] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) -#### 3. PoC 결과가 있어야 정할 수 있는 것 (병행 불가, 값만 비워둠) +#### 3. PoC 결과가 있어야 정할 수 있는 것 (맨 마지막 — 그 전엔 손대지 않는다) + +**§2의 워크스트림(2.1~2.6)이 전부 끝나기 전까지는 이 섹션에 손대지 않는다.** 순서를 앞당겨서 +미리 추측값을 넣지 않는다 — PoC 데이터 없이 정하면 나중에 다시 뒤집어야 하고, 그게 더 큰 낭비다. +§2가 다 끝난 뒤에만 이 섹션으로 돌아온다. - [ ] 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 - [ ] 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 @@ -80,13 +84,16 @@ #### 4. 권장 착수 순서 (진행 상황) +순서대로 하나씩 완료하고 다음으로 넘어간다. **6번(§3)은 1~5번이 전부 끝난 뒤에만 시작한다** — +중간에 끼워 넣지 않는다. + 1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — 설계 참고용으로 남기고 Go로 포팅 필요) 2. [ ] 2.1 코어 백엔드를 Go로 (신규 구현) + 2.3 Flutter 채팅 UI 뼈대 (병행) — **다음 작업** 3. [ ] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA -6. [ ] PoC 결과 반영 → §3 확정 → 2.6 베타 오픈 +6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 §3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. From 39025974189f2bf1fae172bb5f556b0550886efa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:42:48 +0000 Subject: [PATCH 25/33] Implement core backend in Go (item 2 of the build order) Ports the Python prototype (backend/) to the actual chosen stack -- Gin + gorilla/websocket + GORM, same DB schema (models.go mirrors backend/app/models.py), same endpoints (signup, message send, WebSocket relay). backend/ stays as a reference prototype, not removed. Verified with go test: signup, duplicate-invite-code rejection (409), 404 on an unknown conversation, and WebSocket broadcast delivery all pass -- the same cases the Python version was checked against. Push notifications, AI service integration, and multi-device sync are not in this commit -- see core-backend/README.md. --- .gitignore | 3 + core-backend/README.md | 47 +++++++++++++ core-backend/config.go | 20 ++++++ core-backend/db.go | 32 +++++++++ core-backend/go.mod | 48 +++++++++++++ core-backend/go.sum | 103 ++++++++++++++++++++++++++++ core-backend/main.go | 138 ++++++++++++++++++++++++++++++++++++++ core-backend/main_test.go | 126 ++++++++++++++++++++++++++++++++++ core-backend/models.go | 101 ++++++++++++++++++++++++++++ core-backend/relay.go | 47 +++++++++++++ docs/roadmap.md | 16 +++-- 11 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 core-backend/README.md create mode 100644 core-backend/config.go create mode 100644 core-backend/db.go create mode 100644 core-backend/go.mod create mode 100644 core-backend/go.sum create mode 100644 core-backend/main.go create mode 100644 core-backend/main_test.go create mode 100644 core-backend/models.go create mode 100644 core-backend/relay.go diff --git a/.gitignore b/.gitignore index 7152287..0f4b314 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ __pycache__/ # Local dev DB (backend/, SQLite fallback for DATABASE_URL) *.db + +# Go build output (core-backend/) +/core-backend/core-backend diff --git a/core-backend/README.md b/core-backend/README.md new file mode 100644 index 0000000..ec7a171 --- /dev/null +++ b/core-backend/README.md @@ -0,0 +1,47 @@ +# 분신 core-backend (Go) + +Phase 1 코어 백엔드 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 `docs/tech-design.md` §8 참고 — +Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개발). `../backend/`(Python +프로토타입)의 동작을 그대로 재현한 것이다 — API·DB 스키마는 거기서 이미 검증된 것과 동일하다. + +## 실행 + +```bash +go mod download +go run . +``` + +기본은 `sqlite:./dev.db`로 뜬다. 프로덕션 DB를 쓰려면: + +```bash +export DATABASE_URL="postgres://user:pass@host/dbname" +``` + +## 테스트 + +```bash +go test ./... -v +``` + +`main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket +브로드캐스트까지 전부 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 +케이스). + +## 지금 있는 것 (2.1 코어 백엔드) + +- `GET /health` — 헬스체크 +- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409) +- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트 +- `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) +- DB 모델 (`models.go`): `users`, `contacts`, `conversations`, `conversation_participants`, + `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs` — `../backend/app/models.py`와 + 동일한 스키마 + +## 아직 없는 것 (다음 워크스트림) + +- 2.2 AI 서비스 연동 — Python AI 서비스(`poc/tone-corpus/`를 감싼 것)를 `AI_SERVICE_URL`로 호출 + (`config.go`에 자리만 만들어둠, 실제 호출 로직은 아직 없음) +- 푸시 알림 연동 +- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음) +- 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입) +- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우) diff --git a/core-backend/config.go b/core-backend/config.go new file mode 100644 index 0000000..f0a0242 --- /dev/null +++ b/core-backend/config.go @@ -0,0 +1,20 @@ +package main + +import "os" + +// Production: postgresql connection string via DATABASE_URL (tech-design.md §8). +// Defaults to a local SQLite file so the service runs without Postgres during +// development/testing. +func databaseURL() string { + if v := os.Getenv("DATABASE_URL"); v != "" { + return v + } + return "sqlite:./dev.db" +} + +func aiServiceURL() string { + if v := os.Getenv("AI_SERVICE_URL"); v != "" { + return v + } + return "http://localhost:8001" +} diff --git a/core-backend/db.go b/core-backend/db.go new file mode 100644 index 0000000..70f03ac --- /dev/null +++ b/core-backend/db.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "strings" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openDB() *gorm.DB { + url := databaseURL() + + var dialector gorm.Dialector + if strings.HasPrefix(url, "sqlite:") { + dialector = sqlite.Open(strings.TrimPrefix(url, "sqlite:")) + } else { + dialector = postgres.Open(url) + } + + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + + if err := db.AutoMigrate(allModels...); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + return db +} diff --git a/core-backend/go.mod b/core-backend/go.mod new file mode 100644 index 0000000..f4613b5 --- /dev/null +++ b/core-backend/go.mod @@ -0,0 +1,48 @@ +module hikikomori/core-backend + +go 1.25.0 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + gorm.io/gorm v1.31.2 // indirect +) diff --git a/core-backend/go.sum b/core-backend/go.sum new file mode 100644 index 0000000..182699d --- /dev/null +++ b/core-backend/go.sum @@ -0,0 +1,103 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/core-backend/main.go b/core-backend/main.go new file mode 100644 index 0000000..d9705ea --- /dev/null +++ b/core-backend/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type signupRequest struct { + InviteCode string `json:"invite_code" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` +} + +type sendMessageRequest struct { + SenderID uint `json:"sender_id" binding:"required"` + Text string `json:"text" binding:"required"` + SenderMode SenderMode `json:"sender_mode"` +} + +func setupRouter(db *gorm.DB, relay *ConnectionManager) *gin.Engine { + r := gin.Default() + + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + r.POST("/auth/signup", func(c *gin.Context) { + var req signupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + var existing User + if err := db.Where("invite_code = ?", req.InviteCode).First(&existing).Error; err == nil { + c.JSON(http.StatusConflict, gin.H{"detail": "invite_code already used"}) + return + } + + user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName} + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) + + c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName}) + }) + + r.POST("/conversations/:id/messages", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + var req sendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if req.SenderMode == "" { + req.SenderMode = SenderHuman + } + + message := Message{ + ConversationID: convID, + SenderID: req.SenderID, + SenderMode: req.SenderMode, + Text: req.Text, + } + if err := db.Create(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + relay.broadcast(convID, gin.H{ + "id": message.ID, + "sender_id": message.SenderID, + "sender_mode": message.SenderMode, + "text": message.Text, + }) + + c.JSON(http.StatusOK, gin.H{"id": message.ID}) + }) + + r.GET("/ws/conversations/:id", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + relay.add(convID, conn) + defer relay.remove(convID, conn) + + for { + if _, _, err := conn.ReadMessage(); err != nil { + break + } + } + }) + + return r +} + +func parseUintParam(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": name + " must be a positive integer"}) + return 0, false + } + return uint(id), true +} + +func main() { + db := openDB() + relay := newConnectionManager() + r := setupRouter(db, relay) + r.Run(":8080") +} diff --git a/core-backend/main_test.go b/core-backend/main_test.go new file mode 100644 index 0000000..9a2bdaf --- /dev/null +++ b/core-backend/main_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) { + t.Helper() + dbPath := t.TempDir() + "/test.db" + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(allModels...); err != nil { + t.Fatalf("migrate: %v", err) + } + + gin.SetMode(gin.TestMode) + router := setupRouter(db, newConnectionManager()) + server := httptest.NewServer(router) + t.Cleanup(server.Close) + return server, db +} + +func postJSON(t *testing.T, url string, body interface{}) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + resp, err := http.Post(url, "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatalf("post %s: %v", url, err) + } + return resp +} + +func TestHealth(t *testing.T) { + server, _ := setupTestServer(t) + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestSignupAndDuplicateRejected(t *testing.T) { + server, _ := setupTestServer(t) + + resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "지우"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + if out["display_name"] != "지우" { + t.Fatalf("unexpected body: %v", out) + } + + dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "dup"}) + if dup.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 for duplicate invite_code, got %d", dup.StatusCode) + } +} + +func TestSendMessageToMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/messages", sendMessageRequest{SenderID: 1, Text: "hi"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestSendMessageAndWebSocketBroadcast(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "sender1", DisplayName: "정우"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" + + strconv.FormatUint(uint64(conv.ID), 10) + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial ws: %v", err) + } + defer ws.Close() + + sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, + Text: "안녕하세요", + SenderMode: SenderTwin, + }) + if sendResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode) + } + + var received map[string]interface{} + if err := ws.ReadJSON(&received); err != nil { + t.Fatalf("read ws message: %v", err) + } + if received["text"] != "안녕하세요" || received["sender_mode"] != "twin" { + t.Fatalf("unexpected ws payload: %v", received) + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/core-backend/models.go b/core-backend/models.go new file mode 100644 index 0000000..10b97c2 --- /dev/null +++ b/core-backend/models.go @@ -0,0 +1,101 @@ +package main + +import "time" + +type SenderMode string + +const ( + SenderHuman SenderMode = "human" + SenderTwin SenderMode = "twin" +) + +type AutonomyLevel string + +const ( + AutonomyL0 AutonomyLevel = "L0" + AutonomyL1 AutonomyLevel = "L1" + AutonomyL2 AutonomyLevel = "L2" +) + +type User struct { + ID uint `gorm:"primaryKey"` + InviteCode string `gorm:"uniqueIndex;not null"` + DisplayName string `gorm:"not null"` + CreatedAt time.Time +} + +// Contact carries per-peer state like the veto flag (tech-design.md §4: +// twin_disabled_by_peer), independent of any one conversation. +type Contact struct { + ID uint `gorm:"primaryKey"` + OwnerUserID uint `gorm:"not null;index"` + ContactUserID *uint + DisplayName string `gorm:"not null"` + RelationshipNote string + TwinDisabledByPeer bool `gorm:"not null;default:false"` + CreatedAt time.Time +} + +type Conversation struct { + ID uint `gorm:"primaryKey"` + IsGroup bool `gorm:"not null;default:false"` + CreatedAt time.Time +} + +type ConversationParticipant struct { + ID uint `gorm:"primaryKey"` + ConversationID uint `gorm:"not null;index"` + UserID uint `gorm:"not null;index"` +} + +type Message struct { + ID uint `gorm:"primaryKey"` + ConversationID uint `gorm:"not null;index"` + SenderID uint `gorm:"not null"` + SenderMode SenderMode `gorm:"not null;default:human"` + Text string `gorm:"not null"` + CreatedAt time.Time +} + +type TwinSettings struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex;not null"` + AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the +// owner has approved for unattended replies. ContactID nil = any counterpart +// (PRD.md §3.1). +type WhitelistRule struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + ContactID *uint + TopicKeyword string `gorm:"not null"` + CreatedAt time.Time +} + +// EscalationLog is one row per escalation_filter trigger -- the post-hoc +// notification + undo trail required by AGENTS.md's absolute safety +// invariants. +type EscalationLog struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + ConversationID uint `gorm:"not null;index"` + Reason string `gorm:"not null"` + MessageSnippet string `gorm:"not null"` + Resolved bool `gorm:"not null;default:false"` + CreatedAt time.Time +} + +var allModels = []interface{}{ + &User{}, + &Contact{}, + &Conversation{}, + &ConversationParticipant{}, + &Message{}, + &TwinSettings{}, + &WhitelistRule{}, + &EscalationLog{}, +} diff --git a/core-backend/relay.go b/core-backend/relay.go new file mode 100644 index 0000000..17e7269 --- /dev/null +++ b/core-backend/relay.go @@ -0,0 +1,47 @@ +package main + +import ( + "sync" + + "github.com/gorilla/websocket" +) + +// ConnectionManager fans out messages to WebSocket connections per +// conversation. In-memory, single-process -- fine for a small closed beta +// (roadmap.md Phase 1); revisit if the relay needs to scale past one process. +type ConnectionManager struct { + mu sync.Mutex + conns map[uint][]*websocket.Conn +} + +func newConnectionManager() *ConnectionManager { + return &ConnectionManager{conns: make(map[uint][]*websocket.Conn)} +} + +func (m *ConnectionManager) add(conversationID uint, conn *websocket.Conn) { + m.mu.Lock() + defer m.mu.Unlock() + m.conns[conversationID] = append(m.conns[conversationID], conn) +} + +func (m *ConnectionManager) remove(conversationID uint, conn *websocket.Conn) { + m.mu.Lock() + defer m.mu.Unlock() + peers := m.conns[conversationID] + for i, c := range peers { + if c == conn { + m.conns[conversationID] = append(peers[:i], peers[i+1:]...) + break + } + } +} + +func (m *ConnectionManager) broadcast(conversationID uint, payload interface{}) { + m.mu.Lock() + peers := append([]*websocket.Conn(nil), m.conns[conversationID]...) + m.mu.Unlock() + + for _, c := range peers { + _ = c.WriteJSON(payload) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 08af1a5..4aec0aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,14 +33,15 @@ #### 2. 워크스트림별 작업 **2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능) -- [ ] 계정/인증 (초대 코드 기반 가입) — Python(`backend/app/main.py`)으로 프로토타입 구현·검증 - 완료(중복 코드 409 등), **Go로 포팅 필요** (스택 결정이 Python→Go로 바뀜) -- [ ] 메시지 릴레이 서버 (송수신) — 마찬가지로 Python 프로토타입은 WebSocket 브로드캐스트까지 - 검증됨, **Go(`gorilla/websocket` 등)로 포팅 필요**. 멀티 디바이스 동기화는 프로토타입에도 아직 없음 -- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules - — 스키마 설계 자체는 `backend/app/models.py`(SQLAlchemy)로 확정됨, Go 쪽 ORM(`pgx`/GORM)으로 재작성 +- [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함 +- [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함. + 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 +- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules + — `core-backend/models.go` (GORM), `backend/app/models.py`(Python 프로토타입)와 동일 스키마 - [ ] 푸시 알림 서비스 연동 +`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. + **2.2 AI 서비스** (Python, PoC 스크립트 → 내부 API로 승격) - [ ] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 FastAPI 서비스로 승격 (Go 코어가 내부망 HTTP로 호출) @@ -89,7 +90,8 @@ 1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — 설계 참고용으로 남기고 Go로 포팅 필요) -2. [ ] 2.1 코어 백엔드를 Go로 (신규 구현) + 2.3 Flutter 채팅 UI 뼈대 (병행) — **다음 작업** +2. [~] 2.1 코어 백엔드 Go 구현 — `core-backend/` 완료(가입·메시지·WebSocket 릴레이, 푸시 알림만 남음). + 2.3 Flutter 채팅 UI 뼈대는 아직 — **다음 작업** 3. [ ] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA From 0abe97def8cd45fb92354c0f5d89d96394285025 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:06:11 +0000 Subject: [PATCH 26/33] Reorder build: AI service before Flutter client 2.3 (Flutter) needs a Flutter/Dart SDK this environment doesn't have, so it can't be built or verified here the way core-backend and the AI pipeline can. Swapping it with 2.2 (Python AI service) keeps work unblocked instead of writing unverifiable Flutter code; 2.3 moves to wherever the user has the Flutter SDK installed. --- docs/roadmap.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4aec0aa..f6548f1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,7 +51,7 @@ - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 - [ ] 사후 알림 + 되돌리기 로그 스키마/API -**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** +**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수** - [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 - [ ] 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 - [ ] 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 @@ -90,9 +90,11 @@ 1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — 설계 참고용으로 남기고 Go로 포팅 필요) -2. [~] 2.1 코어 백엔드 Go 구현 — `core-backend/` 완료(가입·메시지·WebSocket 릴레이, 푸시 알림만 남음). - 2.3 Flutter 채팅 UI 뼈대는 아직 — **다음 작업** -3. [ ] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) +2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). + 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 + 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 +3. [~] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) — **순서를 앞당김**(원래 2.3과 병행 + 예정이었으나 2.3이 막혀서), 이 환경에서 계속 진행 가능 — **다음 작업** 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 From e8cf48074f566cc2f9275c569ef021b34b644e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:09:19 +0000 Subject: [PATCH 27/33] Promote PoC scripts to the AI service (item 2.2) ai-service/ wraps generate_draft/escalation_filter/retrieve_style behind a single POST /draft endpoint that the Go core will call internally. poc/tone-corpus/ stays untouched for corpus experiments and blind-eval; this is the promoted copy meant for the real service. Verified with TestClient: style_examples path, history/retrieval path (confirms the earlier scoring fix still ranks the on-topic exemplar first), escalation short-circuit, and 422 validation when zero or both of style_examples/history are given. Still missing: the Go core's actual HTTP client calling this service. --- ai-service/README.md | 50 +++++++++++++++++ ai-service/app/__init__.py | 0 ai-service/app/escalation_filter.py | 56 +++++++++++++++++++ ai-service/app/generation.py | 85 +++++++++++++++++++++++++++++ ai-service/app/main.py | 51 +++++++++++++++++ ai-service/app/retrieve_style.py | 40 ++++++++++++++ ai-service/requirements.txt | 4 ++ docs/roadmap.md | 10 ++-- 8 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 ai-service/README.md create mode 100644 ai-service/app/__init__.py create mode 100644 ai-service/app/escalation_filter.py create mode 100644 ai-service/app/generation.py create mode 100644 ai-service/app/main.py create mode 100644 ai-service/app/retrieve_style.py create mode 100644 ai-service/requirements.txt diff --git a/ai-service/README.md b/ai-service/README.md new file mode 100644 index 0000000..acaf786 --- /dev/null +++ b/ai-service/README.md @@ -0,0 +1,50 @@ +# 분신 AI service (Python) + +Phase 1 AI 서비스 (`docs/roadmap.md` Phase 1 §2.2). `poc/tone-corpus/`의 세 스크립트 +(`generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`)를 그대로 승격한 내부 API — +`core-backend/`(Go)가 이 서비스를 내부망 HTTP로 호출한다 (`tech-design.md` §8). + +`poc/tone-corpus/`는 그대로 둔다 — 거긴 코퍼스 실험/블라인드 평가용 PoC 도구로 계속 쓰고, +여기 코드가 실제로 서비스에 쓰이는 "승격된" 버전이다. 두 곳의 로직은 지금 동일하지만, 앞으로 +갈라질 수 있다(예: 여기는 프로덕션 안정성 위주로만 바뀌고, PoC 쪽은 계속 실험적으로 바뀌는 식). + +## 실행 + +```bash +pip install -r requirements.txt +export GEMINI_API_KEY=... # 또는 이 디렉토리에 .env 파일 +uvicorn app.main:app --reload --port 8001 +``` + +## API + +### `POST /draft` + +```json +{ + "context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"], + "style_examples": ["ㅇㅇ 좋지", "나도 궁금하네ㅋㅋ"] +} +``` + +`style_examples`(직접 큐레이션) 또는 `history`(과거 발화 전체, 자동 검색 — `k`로 개수 조절) 중 +**정확히 하나만** 넣는다. 둘 다 넣거나 둘 다 안 넣으면 422. + +응답: + +```json +{ "status": "ok" | "escalate" | "no_key", "text": "..." } +``` + +- `ok`: `text`는 생성된 답장 초안 +- `escalate`: `text`는 에스컬레이션 사유 (금전/약속 확정/감정적으로 무거운 주제) — 이 경우 + LLM은 호출되지 않는다 (`escalation_filter.py`가 하드 게이트) +- `no_key`: `GEMINI_API_KEY`가 없어서 실제 전송될 프롬프트만 `text`에 담아 반환 + +TestClient로 style_examples/history 두 경로, 에스컬레이션 케이스, 검증 오류(422) 전부 확인함. + +## 아직 없는 것 + +- `core-backend/`에서 이 서비스를 실제로 호출하는 클라이언트 코드 (지금은 이 서비스 자체만 있음) +- 온디바이스 말투 이력 저장 (이건 클라이언트/코어 백엔드 쪽 책임 — `tech-design.md` §2 참고) +- 사후 알림 + 되돌리기 로그 (코어 백엔드의 `escalation_logs` 테이블과 연동 필요) diff --git a/ai-service/app/__init__.py b/ai-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/app/escalation_filter.py b/ai-service/app/escalation_filter.py new file mode 100644 index 0000000..b350a03 --- /dev/null +++ b/ai-service/app/escalation_filter.py @@ -0,0 +1,56 @@ +#!/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. `generation.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. + +Identical to poc/tone-corpus/escalation_filter.py -- promoted here +verbatim per roadmap.md Phase 1 §2.2 ("PoC 스크립트를 FastAPI로 승격"). +""" +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) diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py new file mode 100644 index 0000000..bfc6d6f --- /dev/null +++ b/ai-service/app/generation.py @@ -0,0 +1,85 @@ +"""Tone-matched reply drafting -- the core of `generate_draft.py` +(`poc/tone-corpus/`), promoted here as a FastAPI-callable library per +roadmap.md Phase 1 §2.2. This is the server-fallback path described in +`docs/tech-design.md` §2 ("온디바이스 부족시 서버 LLM 호출, 최소 컨텍스트만 +전송") -- a real on-device model would replace the API call later, but the +prompt contract (exemplars + context in, one draft out) stays the same. +""" +import os +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 (service root preferred).""" + if os.environ.get("GEMINI_API_KEY"): + return + here = Path(__file__).resolve() + candidates = [here.parent.parent / ".env", Path.cwd() / ".env"] + for env_path in candidates: + if not env_path.is_file(): + continue + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("'").strip('"') + if key and key not in os.environ: + os.environ[key] = value + break + + +SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \ +그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라. + +지켜야 할 것: +- 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다. +- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ +[ESCALATE] 이 내용은 본인 확인이 필요합니다. +- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. + +(이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 +내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" + + +def build_user_prompt(style_examples, context_lines): + examples = "\n".join(f"- {s}" for s in style_examples) + context = "\n".join(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 draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None): + """Returns (status, text). status is one of "escalate" | "no_key" | "ok". + + "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). + "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). + "ok": text is the generated draft. + """ + gate = check_escalation(last_incoming_text(context_lines)) + if gate.escalate: + return "escalate", gate.reason + + api_key = api_key or os.environ.get("GEMINI_API_KEY") + if not api_key: + return "no_key", build_user_prompt(style_examples, context_lines) + + from google import genai # pip install google-genai + from google.genai import types + + client = genai.Client(api_key=api_key) + resp = client.models.generate_content( + model=model, + contents=build_user_prompt(style_examples, context_lines), + config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300), + ) + return "ok", resp.text.strip() diff --git a/ai-service/app/main.py b/ai-service/app/main.py new file mode 100644 index 0000000..cc6f839 --- /dev/null +++ b/ai-service/app/main.py @@ -0,0 +1,51 @@ +from typing import List, Optional + +from fastapi import FastAPI +from pydantic import BaseModel, model_validator + +from .generation import draft_reply, last_incoming_text, load_dotenv_if_present +from .retrieve_style import retrieve as retrieve_style_examples + +app = FastAPI(title="분신 AI service") + + +@app.on_event("startup") +def startup(): + load_dotenv_if_present() + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +class DraftRequest(BaseModel): + context_lines: List[str] + style_examples: Optional[List[str]] = None + history: Optional[List[str]] = None + k: int = 6 + model: str = "gemini-2.5-flash" + + @model_validator(mode="after") + def check_exactly_one_style_source(self): + if bool(self.style_examples) == bool(self.history): + raise ValueError("provide exactly one of style_examples or history") + return self + + +class DraftResponse(BaseModel): + status: str + text: str + + +@app.post("/draft", response_model=DraftResponse) +def draft(req: DraftRequest): + if req.style_examples is not None: + style_examples = req.style_examples + else: + style_examples = retrieve_style_examples( + req.history, last_incoming_text(req.context_lines), k=req.k + ) + + status, text = draft_reply(style_examples, req.context_lines, model=req.model) + return DraftResponse(status=status, text=text) diff --git a/ai-service/app/retrieve_style.py b/ai-service/app/retrieve_style.py new file mode 100644 index 0000000..bea46aa --- /dev/null +++ b/ai-service/app/retrieve_style.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Keyword/recency-based retrieval -- the search step of the personalization +layer in `docs/tech-design.md` §2-1: "지금부터 임베딩 인프라를 먼저 만들지 +않는다" -- so this is plain token overlap + recency, not embeddings. + +Identical to poc/tone-corpus/retrieve_style.py -- promoted here verbatim +per roadmap.md Phase 1 §2.2. +""" +import re + +TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]+") + + +def tokenize(text): + return set(TOKEN_RE.findall(text)) + + +def _jaccard(a, b): + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def retrieve(history_messages, query_text, k=6): + """history_messages: oldest-first list of past messages by the person. + Ranked by keyword overlap with query_text first; recency only breaks + ties. A weighted-sum of the two was tried and dropped -- Korean short + messages rarely share more than one or two tokens (no particle + stripping here, so "핀란드" and "핀란드는" don't match each other), + so any nonzero recency weight ended up drowning out real overlap and + just returning the most recent messages regardless of topic.""" + query_tokens = tokenize(query_text) + n = len(history_messages) + scored = [] + for i, msg in enumerate(history_messages): + overlap = _jaccard(tokenize(msg), query_tokens) + recency = i / max(n - 1, 1) + scored.append(((overlap, recency), msg)) + scored.sort(key=lambda pair: pair[0], reverse=True) + return [msg for _, msg in scored[:k]] diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt new file mode 100644 index 0000000..7ba5599 --- /dev/null +++ b/ai-service/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.0.0 +google-genai>=1.0.0 diff --git a/docs/roadmap.md b/docs/roadmap.md index f6548f1..7fc4137 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -43,8 +43,10 @@ `backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. **2.2 AI 서비스** (Python, PoC 스크립트 → 내부 API로 승격) -- [ ] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 - FastAPI 서비스로 승격 (Go 코어가 내부망 HTTP로 호출) +- [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 + FastAPI 서비스로 승격 — `ai-service/` (`POST /draft`, style_examples/history 두 경로 + + 에스컬레이션 하드게이트 + 검증 오류 전부 실제 테스트로 확인함) +- [ ] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) - [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 (`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디 책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음) @@ -93,8 +95,8 @@ 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 -3. [~] 2.2 AI 서비스 (Python, PoC 스크립트를 FastAPI로 승격) — **순서를 앞당김**(원래 2.3과 병행 - 예정이었으나 2.3이 막혀서), 이 환경에서 계속 진행 가능 — **다음 작업** +3. [~] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어에서 실제 호출하는 연동 코드는 아직 — + **다음 작업**. 2.3 Flutter는 여전히 로컬 환경 대기 중 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 From fb06306d04a7ac61fca6471cf95d8c54120db011 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:15:32 +0000 Subject: [PATCH 28/33] =?UTF-8?q?Go=20=EC=BD=94=EC=96=B4=EC=97=90=EC=84=9C?= =?UTF-8?q?=20AI=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=ED=98=B8=EC=B6=9C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIServiceClient.requestDraft가 ai-service/의 POST /draft를 호출하고, core-backend에 POST /conversations/:id/draft 라우트를 추가해 프록시한다. mock AI 서비스로 정상 응답·404(대화 없음)·400(스타일 소스 없음) 케이스를 테스트로 확인. roadmap.md Phase 1 §2.2 체크리스트 반영. --- core-backend/aiservice.go | 54 ++++++++++++++++++++++++++++ core-backend/main.go | 52 +++++++++++++++++++++++++-- core-backend/main_test.go | 75 ++++++++++++++++++++++++++++++++++++++- docs/roadmap.md | 13 ++++--- 4 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 core-backend/aiservice.go diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go new file mode 100644 index 0000000..b3659cb --- /dev/null +++ b/core-backend/aiservice.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +// AIServiceClient calls the Python AI service's POST /draft (ai-service/, +// tech-design.md §8's "Go 코어 → Python AI 서비스, 내부망 HTTP"). +type AIServiceClient struct { + BaseURL string + HTTP *http.Client +} + +func newAIServiceClient() *AIServiceClient { + return &AIServiceClient{BaseURL: aiServiceURL(), HTTP: http.DefaultClient} +} + +type draftRequest struct { + ContextLines []string `json:"context_lines"` + StyleExamples []string `json:"style_examples,omitempty"` + History []string `json:"history,omitempty"` + K int `json:"k,omitempty"` +} + +type draftResponse struct { + Status string `json:"status"` + Text string `json:"text"` +} + +func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := c.HTTP.Post(c.BaseURL+"/draft", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ai service unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ai service returned %d", resp.StatusCode) + } + + var out draftResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/core-backend/main.go b/core-backend/main.go index d9705ea..8208afd 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -24,7 +24,14 @@ type sendMessageRequest struct { SenderMode SenderMode `json:"sender_mode"` } -func setupRouter(db *gorm.DB, relay *ConnectionManager) *gin.Engine { +type draftMessageRequest struct { + ContextLines []string `json:"context_lines" binding:"required"` + StyleExamples []string `json:"style_examples"` + History []string `json:"history"` + K int `json:"k"` +} + +func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gin.Engine { r := gin.Default() r.GET("/health", func(c *gin.Context) { @@ -96,6 +103,46 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager) *gin.Engine { c.JSON(http.StatusOK, gin.H{"id": message.ID}) }) + r.POST("/conversations/:id/draft", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + var req draftMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if len(req.StyleExamples) == 0 && len(req.History) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "provide style_examples or history"}) + return + } + + // EscalationLog persistence (sender's post-hoc notification + undo + // trail) is a separate checklist item -- roadmap.md Phase 1 §2.2 + // "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies + // to the AI service for now. + result, err := ai.requestDraft(draftRequest{ + ContextLines: req.ContextLines, + StyleExamples: req.StyleExamples, + History: req.History, + K: req.K, + }) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) + }) + r.GET("/ws/conversations/:id", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { @@ -133,6 +180,7 @@ func parseUintParam(c *gin.Context, name string) (uint, bool) { func main() { db := openDB() relay := newConnectionManager() - r := setupRouter(db, relay) + ai := newAIServiceClient() + r := setupRouter(db, relay, ai) r.Run(":8080") } diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 9a2bdaf..b087f8b 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -16,6 +16,28 @@ import ( "gorm.io/gorm" ) +// mockAIService stands in for ai-service/ during tests -- it echoes back +// a canned response so core-backend's HTTP client code (not the Python +// service itself) is what's under test here. +func mockAIService(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/draft" { + w.WriteHeader(http.StatusNotFound) + return + } + var req draftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) + })) + t.Cleanup(server.Close) + return server +} + func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) { t.Helper() dbPath := t.TempDir() + "/test.db" @@ -27,8 +49,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) { t.Fatalf("migrate: %v", err) } + ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient} + gin.SetMode(gin.TestMode) - router := setupRouter(db, newConnectionManager()) + router := setupRouter(db, newConnectionManager(), ai) server := httptest.NewServer(router) t.Cleanup(server.Close) return server, db @@ -121,6 +145,55 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { } } +func TestDraftMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + StyleExamples: []string{"ㅇㅇ 좋지"}, + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestDraftRequiresStyleSource(t *testing.T) { + server, db := setupTestServer(t) + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestDraftProxiesToAIService(t *testing.T) { + server, db := setupTestServer(t) + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + StyleExamples: []string{"ㅇㅇ 좋지"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out draftResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Status != "ok" || !strings.Contains(out.Text, "오늘 저녁에 뭐 먹을래") { + t.Fatalf("unexpected draft response: %+v", out) + } +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } diff --git a/docs/roadmap.md b/docs/roadmap.md index 7fc4137..804a087 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -46,7 +46,9 @@ - [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 FastAPI 서비스로 승격 — `ai-service/` (`POST /draft`, style_examples/history 두 경로 + 에스컬레이션 하드게이트 + 검증 오류 전부 실제 테스트로 확인함) -- [ ] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) +- [x] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) + — `core-backend/aiservice.go`(`AIServiceClient.requestDraft`) + `POST /conversations/:id/draft` + 라우트, mock AI 서비스로 정상 프록시·404·400(스타일 소스 없음) 전부 실제 테스트로 확인함 - [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 (`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디 책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음) @@ -95,9 +97,12 @@ 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 -3. [~] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어에서 실제 호출하는 연동 코드는 아직 — - **다음 작업**. 2.3 Flutter는 여전히 로컬 환경 대기 중 -4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) +3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, + `POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스 + 말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는 + 여전히 로컬 환경 대기 중 +4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) — **다음 작업 대상은 이 항목이지만 Flutter SDK가 없는 이 + 환경에서는 착수 불가. 2.4(안전장치 통합)으로 순서를 당겨 진행** 5. [ ] 2.4/2.5 안전장치·QA 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 From 9041dc9b64da699fcb4b931d104b93fdb7b76049 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:26:02 +0000 Subject: [PATCH 29/33] =?UTF-8?q?=EC=97=90=EC=8A=A4=EC=BB=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=ED=95=98=EB=93=9C=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=B0=ED=9A=8C=20=EA=B5=AC=EB=A9=8D=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20+=20=EC=84=9C=EB=B2=84=20=EC=B8=A1=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발견한 문제: POST /conversations/:id/messages가 sender_mode=twin을 검증 없이 그대로 저장·브로드캐스트하고 있었음 -- 에스컬레이션 게이트는 초안 생성(/draft) 경로에만 있었고 실제 발송 경로엔 없어서, 클라이언트가 /draft를 거치지 않고 바로 twin 메시지를 보내면 안전선을 완전히 우회할 수 있었다. - ai-service: /draft와 별개인 POST /escalate/check 하드게이트 엔드포인트 추가 - core-backend: AIServiceClient.checkEscalation 추가, 메시지 저장 직전에 twin 발송이면 무조건 호출하도록 해서 발송이 실제로 일어나는 단 하나의 지점에서 막음. AI 서비스 응답 불가 시 fail-safe로 발송 차단. 에스컬레이션되면 저장/브로드캐스트 없이 escalation_logs에만 기록. 사람이 직접 보내는 메시지는 게이트 대상 아님 - core-backend: DELETE /users/:id 추가 -- 유저가 걸린 모든 행(트윈 설정· 화이트리스트·연락처·대화참여·메시지·에스컬레이션로그·유저 본인)을 트랜잭션으로 삭제 (tech-design.md §5 "사용자가 언제든 초기화 가능") - 온디바이스 암호화·데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경에서는 보류, roadmap.md에 근거 남김 --- ai-service/README.md | 17 ++- ai-service/app/main.py | 20 ++++ core-backend/README.md | 42 ++++++- core-backend/aiservice.go | 37 ++++++ core-backend/main.go | 79 +++++++++++++ core-backend/main_test.go | 242 ++++++++++++++++++++++++++++++++++++-- docs/roadmap.md | 22 +++- 7 files changed, 437 insertions(+), 22 deletions(-) diff --git a/ai-service/README.md b/ai-service/README.md index acaf786..d5827c5 100644 --- a/ai-service/README.md +++ b/ai-service/README.md @@ -18,6 +18,19 @@ uvicorn app.main:app --reload --port 8001 ## API +### `POST /escalate/check` + +```json +{ "text": "계좌번호 알려줄래?" } +``` + +응답: `{ "escalate": true, "reason": "금전" }` + +`/draft`와 별개로 존재하는 독립 하드게이트 엔드포인트 — `core-backend`가 트윈(자동발송) 메시지를 +저장하기 *직전에* 이걸 직접 호출해서, `/draft`를 거치지 않은 발송 경로도 전부 이 게이트를 통과하게 +만든다 (`docs/roadmap.md` Phase 1 §2.4, `AGENTS.md` 안전 불변식). TestClient로 금전/약속/감정 +케이스와 비대상 텍스트 전부 확인함. + ### `POST /draft` ```json @@ -45,6 +58,6 @@ TestClient로 style_examples/history 두 경로, 에스컬레이션 케이스, ## 아직 없는 것 -- `core-backend/`에서 이 서비스를 실제로 호출하는 클라이언트 코드 (지금은 이 서비스 자체만 있음) - 온디바이스 말투 이력 저장 (이건 클라이언트/코어 백엔드 쪽 책임 — `tech-design.md` §2 참고) -- 사후 알림 + 되돌리기 로그 (코어 백엔드의 `escalation_logs` 테이블과 연동 필요) +- 사후 알림 + 되돌리기 UI/전체 흐름 (코어 백엔드는 이제 에스컬레이션 시 `escalation_logs`에 기록은 + 하지만, 사용자에게 사후 알림을 띄우고 되돌리는 클라이언트 UX는 아직 — Flutter 쪽 작업) diff --git a/ai-service/app/main.py b/ai-service/app/main.py index cc6f839..0a51303 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -3,6 +3,7 @@ from typing import List, Optional from fastapi import FastAPI from pydantic import BaseModel, model_validator +from .escalation_filter import check as check_escalation from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .retrieve_style import retrieve as retrieve_style_examples @@ -38,6 +39,25 @@ class DraftResponse(BaseModel): text: str +class EscalationCheckRequest(BaseModel): + text: str + + +class EscalationCheckResponse(BaseModel): + escalate: bool + reason: str = "" + + +@app.post("/escalate/check", response_model=EscalationCheckResponse) +def escalate_check(req: EscalationCheckRequest): + """Standalone hard gate, decoupled from draft generation (AGENTS.md + absolute safety invariants). core-backend calls this directly before + persisting any twin-authored (auto-sent) message, so the gate applies + even to sends that never went through /draft.""" + result = check_escalation(req.text) + return EscalationCheckResponse(escalate=result.escalate, reason=result.reason) + + @app.post("/draft", response_model=DraftResponse) def draft(req: DraftRequest): if req.style_examples is not None: diff --git a/core-backend/README.md b/core-backend/README.md index ec7a171..8d41c52 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -17,6 +17,12 @@ go run . export DATABASE_URL="postgres://user:pass@host/dbname" ``` +AI 서비스(`../ai-service/`)를 호출하려면: + +```bash +export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 +``` + ## 테스트 ```bash @@ -24,23 +30,47 @@ go test ./... -v ``` `main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket -브로드캐스트까지 전부 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 -케이스). +브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/계정 +삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 +동일한 케이스 + Go에서 새로 추가된 것들). -## 지금 있는 것 (2.1 코어 백엔드) +## 지금 있는 것 +**2.1 코어 백엔드** - `GET /health` — 헬스체크 - `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409) -- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트 +- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트. + `sender_mode: "twin"`인 요청은 저장 전에 반드시 에스컬레이션 하드게이트를 통과해야 한다 (아래 2.4) - `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) +- `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트· + 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제 (`tech-design.md` §5 + "사용자가 언제든 초기화 가능") - DB 모델 (`models.go`): `users`, `contacts`, `conversations`, `conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs` — `../backend/app/models.py`와 동일한 스키마 +**2.2 AI 서비스 연동** +- `POST /conversations/{id}/draft` — `ai-service/`의 `POST /draft`를 호출해 초안을 프록시 반환 + (`style_examples`/`history` 중 하나 필수, 없으면 400) + +**2.4 안전장치 통합** +- `AIServiceClient.checkEscalation` (`aiservice.go`) — `ai-service`의 `POST /escalate/check` 호출. + `/conversations/{id}/messages`가 `sender_mode: "twin"`을 받을 때마다 이걸 먼저 호출해서, 어떤 + 경로로 왔든(그리고 `/draft`를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는 + 하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고 `escalation_logs`에 + 기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를 + 확인 못 했다는 불확실성을 자동발송 허용 쪽으로 풀지 않는다. +- 사람이 직접 보내는 메시지(`sender_mode` 기본값 `human`)는 이 게이트를 타지 않는다 — 본인이 직접 + 쓴 말을 막을 이유가 없다. + ## 아직 없는 것 (다음 워크스트림) -- 2.2 AI 서비스 연동 — Python AI 서비스(`poc/tone-corpus/`를 감싼 것)를 `AI_SERVICE_URL`로 호출 - (`config.go`에 자리만 만들어둠, 실제 호출 로직은 아직 없음) +- 자율성 엔진(L0~L2) 오케스트레이션 전체 흐름 (`roadmap.md` §2.2) — 지금은 발송 시점 하드게이트만 + 있고, 초안 생성→승인 대기→자동발송 분기의 나머지는 아직 +- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임) +- 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) +- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 + 로컬 환경에서 진행) - 푸시 알림 연동 - 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음) - 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입) diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go index b3659cb..7bd6df1 100644 --- a/core-backend/aiservice.go +++ b/core-backend/aiservice.go @@ -52,3 +52,40 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error) } return &out, nil } + +type escalationCheckRequest struct { + Text string `json:"text"` +} + +type escalationCheckResponse struct { + Escalate bool `json:"escalate"` + Reason string `json:"reason"` +} + +// checkEscalation calls ai-service's POST /escalate/check -- the hard gate +// that any twin-authored (auto-sent) message must pass, independent of +// whether a draft was generated through this client. Callers must fail +// closed (treat an error here as "escalate") per AGENTS.md's fail-safe +// invariant: uncertainty must never resolve to an unattended send. +func (c *AIServiceClient) checkEscalation(text string) (*escalationCheckResponse, error) { + body, err := json.Marshal(escalationCheckRequest{Text: text}) + if err != nil { + return nil, err + } + + resp, err := c.HTTP.Post(c.BaseURL+"/escalate/check", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ai service unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ai service returned %d", resp.StatusCode) + } + + var out escalationCheckResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/core-backend/main.go b/core-backend/main.go index 8208afd..7c3f856 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -82,6 +82,31 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi req.SenderMode = SenderHuman } + // Hard gate: a twin-authored send is an unattended action, so it + // must clear escalation_filter regardless of how it got here -- + // this is the one chokepoint every twin auto-send passes through, + // so no client or upstream path can bypass it (AGENTS.md absolute + // safety invariants). Human-authored messages are the human's own + // words and are never gated. On any doubt (AI service unreachable + // or erroring) we fail closed and block the send. + if req.SenderMode == SenderTwin { + result, err := ai.checkEscalation(req.Text) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) + return + } + if result.Escalate { + db.Create(&EscalationLog{ + UserID: req.SenderID, + ConversationID: convID, + Reason: result.Reason, + MessageSnippet: req.Text, + }) + c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason}) + return + } + } + message := Message{ ConversationID: convID, SenderID: req.SenderID, @@ -143,6 +168,60 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) }) + r.DELETE("/users/:id", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + + // Right-to-erasure ("사용자가 언제든 초기화 가능", tech-design.md §5): + // wipes every row this user's ID appears on. Real chat history lives + // on-device first (tech-design.md §2/§5) -- the server only ever held + // a minimal relay copy, so deleting it here is not a partial erasure. + var messagesDeleted, escalationLogsDeleted int64 + err := db.Transaction(func(tx *gorm.DB) error { + if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil { + return res.Error + } + if res := tx.Where("user_id = ?", userID).Delete(&WhitelistRule{}); res.Error != nil { + return res.Error + } + if res := tx.Where("owner_user_id = ?", userID).Delete(&Contact{}); res.Error != nil { + return res.Error + } + if res := tx.Where("user_id = ?", userID).Delete(&ConversationParticipant{}); res.Error != nil { + return res.Error + } + res := tx.Where("sender_id = ?", userID).Delete(&Message{}) + if res.Error != nil { + return res.Error + } + messagesDeleted = res.RowsAffected + res = tx.Where("user_id = ?", userID).Delete(&EscalationLog{}) + if res.Error != nil { + return res.Error + } + escalationLogsDeleted = res.RowsAffected + return tx.Delete(&user).Error + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "deleted_user_id": userID, + "messages_deleted": messagesDeleted, + "escalation_logs_deleted": escalationLogsDeleted, + }) + }) + r.GET("/ws/conversations/:id", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index b087f8b..fa16c61 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -22,17 +23,36 @@ import ( func mockAIService(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/draft" { + switch r.URL.Path { + case "/draft": + var req draftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) + case "/escalate/check": + // Mirrors escalation_filter.py's rule set closely enough to + // exercise core-backend's gating logic; the rules themselves + // are verified against ai-service/app/escalation_filter.py directly. + var req escalationCheckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + out := escalationCheckResponse{} + switch { + case strings.Contains(req.Text, "계좌") || strings.Contains(req.Text, "송금"): + out = escalationCheckResponse{Escalate: true, Reason: "금전"} + case strings.Contains(req.Text, "힘들어"): + out = escalationCheckResponse{Escalate: true, Reason: "감정적으로 무거운 주제"} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) + default: w.WriteHeader(http.StatusNotFound) - return } - var req draftRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) })) t.Cleanup(server.Close) return server @@ -145,6 +165,210 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { } } +func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin1", DisplayName: "민지"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" + + strconv.FormatUint(uint64(conv.ID), 10) + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial ws: %v", err) + } + defer ws.Close() + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, + Text: "계좌번호 알려줄게", + SenderMode: SenderTwin, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for escalated twin send, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("escalated twin message must not be persisted, found %d rows", count) + } + + var logs []EscalationLog + db.Where("conversation_id = ?", conv.ID).Find(&logs) + if len(logs) != 1 || logs[0].Reason != "금전" { + t.Fatalf("expected one EscalationLog row with reason 금전, got %+v", logs) + } + + ws.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + if _, _, err := ws.ReadMessage(); err == nil { + t.Fatal("expected no broadcast for a blocked escalated message") + } +} + +func TestTwinMessageNotEscalatedIsSent(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin2", DisplayName: "하늘"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, + Text: "ㅇㅇ 알겠어", + SenderMode: SenderTwin, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for non-escalated twin send, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 1 { + t.Fatalf("expected 1 persisted message, got %d", count) + } +} + +func TestHumanMessageBypassesEscalationGate(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "human1", DisplayName: "재민"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + // A human typing their own money-related message is never gated -- + // only unattended (twin) sends are. + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, + Text: "계좌번호 불러줄게", + SenderMode: SenderHuman, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for human-authored send, got %d", resp.StatusCode) + } +} + +func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) { + dbPath := t.TempDir() + "/test.db" + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(allModels...); err != nil { + t.Fatalf("migrate: %v", err) + } + + unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + unreachable.Close() // closed immediately -- BaseURL now points at nothing listening + + ai := &AIServiceClient{BaseURL: unreachable.URL, HTTP: http.DefaultClient} + gin.SetMode(gin.TestMode) + router := setupRouter(db, newConnectionManager(), ai) + server := httptest.NewServer(router) + t.Cleanup(server.Close) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin3", DisplayName: "소연"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, + Text: "ㅇㅇ 알겠어", + SenderMode: SenderTwin, + }) + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("expected 502 fail-closed when escalation gate is unreachable, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("must not persist a twin message when the safety gate couldn't be checked, found %d rows", count) + } +} + +func TestDeleteUserPurgesData(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "erase1", DisplayName: "유나"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + userID := uint(user["id"].(float64)) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: userID, + Text: "안녕", + }) + if sendResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode) + } + db.Create(&WhitelistRule{UserID: userID, TopicKeyword: "저녁 약속"}) + db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."}) + + req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete request: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + if out["messages_deleted"].(float64) != 1 || out["escalation_logs_deleted"].(float64) != 1 { + t.Fatalf("unexpected purge summary: %v", out) + } + + var userCount, msgCount, settingsCount, whitelistCount, logCount int64 + db.Model(&User{}).Where("id = ?", userID).Count(&userCount) + db.Model(&Message{}).Where("sender_id = ?", userID).Count(&msgCount) + db.Model(&TwinSettings{}).Where("user_id = ?", userID).Count(&settingsCount) + db.Model(&WhitelistRule{}).Where("user_id = ?", userID).Count(&whitelistCount) + db.Model(&EscalationLog{}).Where("user_id = ?", userID).Count(&logCount) + if userCount != 0 || msgCount != 0 || settingsCount != 0 || whitelistCount != 0 || logCount != 0 { + t.Fatalf("expected all user-linked rows purged, got user=%d msg=%d settings=%d whitelist=%d log=%d", + userCount, msgCount, settingsCount, whitelistCount, logCount) + } + + req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("second delete request: %v", err) + } + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 on repeat delete, got %d", resp2.StatusCode) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/docs/roadmap.md b/docs/roadmap.md index 804a087..41a45bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,8 +63,18 @@ - [ ] 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 **2.4 안전장치 통합** (전 구간 필수, 타협 불가) -- [ ] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 -- [ ] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 +- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 + 구멍: `POST /conversations/:id/messages`가 `sender_mode: "twin"`을 검증 없이 그대로 저장· + 브로드캐스트하고 있었음(초안 생성(`/draft`)만 게이트를 탔고, 발송 자체는 게이트가 없었음). 이걸 + 막기 위해 `ai-service`에 `/draft`와 별개인 `POST /escalate/check` 하드게이트 엔드포인트를 추가하고, + `core-backend`가 트윈 발송 저장 *직전에* 무조건 이걸 호출하도록 만듦 — 어떤 경로로 왔든 발송이 + 실제로 일어나는 단 하나의 지점(메시지 저장)에서 걸리므로 클라이언트가 뭘 하든 우회 불가. + AI 서비스 응답 불가 시 fail-safe(발송 차단, 502)로 처리. 사람이 직접 보내는 메시지는 게이트 대상이 + 아님. 차단·통과·게이트 불능 3케이스 전부 실제 테스트로 확인함 (`core-backend/main_test.go`) +- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 + 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 + 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK + 없음)에서는 진행 불가 — 2.3과 함께 로컬 환경 대기 **2.5 QA/테스트** - [ ] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게 @@ -101,9 +111,11 @@ `POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스 말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는 여전히 로컬 환경 대기 중 -4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) — **다음 작업 대상은 이 항목이지만 Flutter SDK가 없는 이 - 환경에서는 착수 불가. 2.4(안전장치 통합)으로 순서를 당겨 진행** -5. [ ] 2.4/2.5 안전장치·QA +4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. + 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 +5. [~] 2.4/2.5 안전장치·QA — 2.4의 서버 쪽 절반(하드게이트 우회 차단, 삭제 플로우) 완료. 나머지는 + 클라이언트 쪽(온디바이스 암호화·데이터 흐름 대시보드, Flutter 대기)과 2.5 QA(테스트 스위트 승격, + 자율성 플로우 통합 테스트) — 다음 작업 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 From de8b44d53610fbb358a233f020ce27eeba59d35d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:37:36 +0000 Subject: [PATCH 30/33] =?UTF-8?q?2.5=20QA:=20ai-service=20pytest=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=84=ED=8A=B8=20=EC=8A=B9=EA=B2=A9=20+=20?= =?UTF-8?q?=EC=9E=90=EC=9C=A8=EC=84=B1=20=ED=94=8C=EB=A1=9C=EC=9A=B0(L0~L2?= =?UTF-8?q?)=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-service: - escalation_filter/retrieve_style/generation/main(FastAPI)의 ad-hoc TestClient 검증을 ai-service/tests/ 정식 pytest 스위트로 승격(34개). Gemini 호출은 mock, retrieve_style은 overlap이 recency를 항상 이긴다는 것(과거 버그 재발 방지)까지 포함 - on_event(deprecated) -> lifespan 컨텍스트 매니저로 교체 core-backend: 자율성 플로우(L0->L1->L2) 통합 테스트를 쓰려면 실제 분기 로직이 있어야 해서, roadmap.md §2.2에서 미결로 남아있던 자율성 엔진 오케스트레이션 최소 버전을 이번에 구현: - PATCH /users/:id/twin-settings -- 자율성 레벨 변경 (기본값 L0) - 트윈 발송 시 에스컬레이션 통과 후 레벨 확인: L0는 항상 차단, L1은 approved:true 필요, L2는 화이트리스트 매칭 시 즉시 자동발송·매칭 없으면 L1과 동일하게 승인 필요. 에스컬레이션은 레벨/화이트리스트/ 승인 여부와 무관하게 항상 우선(테스트로 확인) - 화이트리스트 매칭은 v1 최소 구현(전역 키워드 매칭, 상대별 예외는 아직) -- 대화방↔연락처 연결이 모델링되지 않아 보류, README에 명시 온보딩·채팅·설정 수동 QA는 Flutter 클라이언트가 없어 이 환경에서는 보류, roadmap.md에 근거 남김 --- ai-service/README.md | 13 ++ ai-service/app/main.py | 11 +- ai-service/pytest.ini | 2 + ai-service/requirements-dev.txt | 2 + ai-service/tests/__init__.py | 0 ai-service/tests/test_escalation_filter.py | 48 +++++++ ai-service/tests/test_generation.py | 75 ++++++++++ ai-service/tests/test_main.py | 92 +++++++++++++ ai-service/tests/test_retrieve_style.py | 50 +++++++ core-backend/README.md | 25 +++- core-backend/main.go | 87 +++++++++++- core-backend/main_test.go | 153 ++++++++++++++++++++- docs/roadmap.md | 37 +++-- 13 files changed, 569 insertions(+), 26 deletions(-) create mode 100644 ai-service/pytest.ini create mode 100644 ai-service/requirements-dev.txt create mode 100644 ai-service/tests/__init__.py create mode 100644 ai-service/tests/test_escalation_filter.py create mode 100644 ai-service/tests/test_generation.py create mode 100644 ai-service/tests/test_main.py create mode 100644 ai-service/tests/test_retrieve_style.py diff --git a/ai-service/README.md b/ai-service/README.md index d5827c5..57ac0fe 100644 --- a/ai-service/README.md +++ b/ai-service/README.md @@ -16,6 +16,19 @@ export GEMINI_API_KEY=... # 또는 이 디렉토리에 .env 파일 uvicorn app.main:app --reload --port 8001 ``` +## 테스트 + +```bash +pip install -r requirements-dev.txt +pytest tests/ -v +``` + +`escalation_filter.py`(SELFTEST_CASES 승격 + 추가 케이스)·`retrieve_style.py`(overlap이 recency를 +항상 이긴다는 것, 동점일 때만 recency가 tie-break한다는 것)·`generation.py`(escalate/no_key/ok 세 +경로, Gemini 호출은 mock)·`main.py`(`/health`·`/escalate/check`·`/draft` 전부)를 `tests/`의 정식 +pytest 스위트로 승격함 (`docs/roadmap.md` Phase 1 §2.5). 이전엔 ad-hoc TestClient 스크립트로만 +확인했던 것들. + ## API ### `POST /escalate/check` diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 0a51303..c58e813 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from typing import List, Optional from fastapi import FastAPI @@ -7,12 +8,14 @@ from .escalation_filter import check as check_escalation from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .retrieve_style import retrieve as retrieve_style_examples -app = FastAPI(title="분신 AI service") - -@app.on_event("startup") -def startup(): +@asynccontextmanager +async def lifespan(app: FastAPI): load_dotenv_if_present() + yield + + +app = FastAPI(title="분신 AI service", lifespan=lifespan) @app.get("/health") diff --git a/ai-service/pytest.ini b/ai-service/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/ai-service/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/ai-service/requirements-dev.txt b/ai-service/requirements-dev.txt new file mode 100644 index 0000000..13f6026 --- /dev/null +++ b/ai-service/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0.0 diff --git a/ai-service/tests/__init__.py b/ai-service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/tests/test_escalation_filter.py b/ai-service/tests/test_escalation_filter.py new file mode 100644 index 0000000..cfd0cd3 --- /dev/null +++ b/ai-service/tests/test_escalation_filter.py @@ -0,0 +1,48 @@ +"""Promotes escalation_filter.py's SELFTEST_CASES (poc/tone-corpus/escalation_filter.py, +identical logic here) into a real pytest suite -- roadmap.md Phase 1 §2.5.""" +import pytest + +from app.escalation_filter import check + +SELFTEST_CASES = [ + ("계좌로 3만원만 보내줘", True, "금전"), + ("그 카페 계좌번호 좀 알려줄래", True, "금전"), + ("그럼 내일 3시 맞지?", True, "약속 확정"), + ("약속 시간 확정하자, 언제가 좋아", True, "약속 확정"), + ("나 요즘 너무 힘들어서 죽고싶다는 생각이 들어", True, "감정적으로 무거운 주제"), + ("우리 어제 왜 그렇게 싸웠어", True, "감정적으로 무거운 주제"), + ("오늘 저녁에 뭐 먹을래?", False, ""), + ("크라비 여행 가보고 싶어", False, ""), + ("고등학교 학점제가 뭔지 설명해줄 수 있어?", False, ""), + ("이 영화 재밌었어? 나도 보고싶다", False, ""), +] + + +@pytest.mark.parametrize("text,expected_escalate,expected_reason", SELFTEST_CASES) +def test_selftest_cases(text, expected_escalate, expected_reason): + result = check(text) + assert result.escalate == expected_escalate + if expected_escalate: + assert result.reason == expected_reason + else: + assert result.reason == "" + + +def test_money_wins_over_no_match_when_both_patterns_could_apply(): + # 금전 패턴이 먼저 검사되므로 금전+약속이 섞여도 이유는 금전으로 고정된다. + result = check("계좌번호 알려주면 내일 3시 맞지?") + assert result.escalate is True + assert result.reason == "금전" + + +def test_empty_text_never_escalates(): + result = check("") + assert result.escalate is False + + +def test_emotional_keyword_substring_match_is_intentionally_broad(): + # 키워드 포함 매칭이라 오탐이 있을 수 있다 -- tech-design.md §3의 "애매하면 + # 항상 에스컬레이션 쪽으로 fail-safe" 원칙과 일치하는 의도된 동작. + result = check("나 미드 정주행하다가 화나는 장면 나와서 잠깐 멈췄어") + assert result.escalate is True + assert result.reason == "감정적으로 무거운 주제" diff --git a/ai-service/tests/test_generation.py b/ai-service/tests/test_generation.py new file mode 100644 index 0000000..1bf4725 --- /dev/null +++ b/ai-service/tests/test_generation.py @@ -0,0 +1,75 @@ +"""Promotes generate_draft.py's manual verification into pytest -- roadmap.md +Phase 1 §2.5. The Gemini call is mocked; this only asserts the three status +branches (escalate/no_key/ok) and the exact request shape sent to the model, +not real generation quality (that's blind_eval.py's job).""" +import sys +import types +from unittest.mock import MagicMock + +from app.generation import build_user_prompt, draft_reply, last_incoming_text + + +def test_last_incoming_text_strips_speaker_prefix(): + assert last_incoming_text(["나: ㅇㅇ", "상대: 오늘 뭐해?"]) == "오늘 뭐해?" + + +def test_last_incoming_text_empty_context(): + assert last_incoming_text([]) == "" + + +def test_build_user_prompt_includes_examples_and_context(): + prompt = build_user_prompt(["ㅋㅋ 그러네"], ["상대: 안녕"]) + assert "ㅋㅋ 그러네" in prompt + assert "상대: 안녕" in prompt + + +def test_draft_reply_escalates_before_any_model_call(): + # api_key is present but escalation must short-circuit before genai is + # even imported -- if this regresses, google.genai.Client below would + # need to exist/be reachable and this test would start hitting real + # import/network paths instead of returning early. + status, text = draft_reply(["ㅇㅇ"], ["상대: 계좌번호 좀 알려줘"], api_key="fake-key") + assert status == "escalate" + assert text == "금전" + + +def test_draft_reply_no_key_returns_prompt_without_calling_model(monkeypatch): + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + status, text = draft_reply(["ㅇㅇ"], ["상대: 오늘 저녁 뭐 먹을래?"], api_key=None) + assert status == "no_key" + assert "오늘 저녁 뭐 먹을래" in text + + +def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch): + # generation.py does `from google import genai` / `from google.genai import + # types` *inside* draft_reply, so we stub those modules in sys.modules + # before the call -- this also sidesteps google-genai's real dependency + # chain (google-auth -> cryptography), which isn't needed for a unit test + # and doesn't import cleanly in every sandbox. + fake_response = MagicMock() + fake_response.text = " ㅇㅇ 좋지 " + fake_client = MagicMock() + fake_client.models.generate_content.return_value = fake_response + + fake_genai = types.ModuleType("google.genai") + fake_genai.Client = MagicMock(return_value=fake_client) + fake_types = types.ModuleType("google.genai.types") + fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw) + fake_google = types.ModuleType("google") + fake_google.genai = fake_genai + + monkeypatch.setitem(sys.modules, "google", fake_google) + monkeypatch.setitem(sys.modules, "google.genai", fake_genai) + monkeypatch.setitem(sys.modules, "google.genai.types", fake_types) + + status, text = draft_reply( + ["ㅇㅇ 좋지"], ["상대: 오늘 저녁 뭐 먹을래?"], model="gemini-2.5-flash", api_key="fake-key" + ) + + assert status == "ok" + assert text == "ㅇㅇ 좋지" # stripped + fake_genai.Client.assert_called_once_with(api_key="fake-key") + fake_client.models.generate_content.assert_called_once() + _, kwargs = fake_client.models.generate_content.call_args + assert kwargs["model"] == "gemini-2.5-flash" + assert "오늘 저녁 뭐 먹을래" in kwargs["contents"] diff --git a/ai-service/tests/test_main.py b/ai-service/tests/test_main.py new file mode 100644 index 0000000..311a398 --- /dev/null +++ b/ai-service/tests/test_main.py @@ -0,0 +1,92 @@ +"""Promotes the ad-hoc TestClient checks used to verify /draft and +/escalate/check into pytest -- roadmap.md Phase 1 §2.5.""" +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health(): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_escalate_check_money(): + resp = client.post("/escalate/check", json={"text": "계좌번호 알려줄래?"}) + assert resp.status_code == 200 + assert resp.json() == {"escalate": True, "reason": "금전"} + + +def test_escalate_check_benign(): + resp = client.post("/escalate/check", json={"text": "오늘 저녁에 뭐 먹을래?"}) + assert resp.status_code == 200 + assert resp.json() == {"escalate": False, "reason": ""} + + +def test_escalate_check_requires_text_field(): + resp = client.post("/escalate/check", json={}) + assert resp.status_code == 422 + + +def test_draft_with_style_examples_no_key(monkeypatch): + # No GEMINI_API_KEY in this test environment -- assert the deterministic + # no_key path rather than hitting the real Gemini API. + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + resp = client.post( + "/draft", + json={ + "context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"], + "style_examples": ["ㅇㅇ 좋지", "나도 궁금하네ㅋㅋ"], + }, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "no_key" + + +def test_draft_escalates_without_calling_style_source_logic(): + resp = client.post( + "/draft", + json={ + "context_lines": ["상대: 계좌번호 좀 알려줘"], + "style_examples": ["ㅇㅇ 알겠어"], + }, + ) + assert resp.status_code == 200 + assert resp.json() == {"status": "escalate", "text": "금전"} + + +def test_draft_with_history_uses_retrieval(monkeypatch): + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + resp = client.post( + "/draft", + json={ + "context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"], + "history": ["어제 저녁엔 라면 먹었어", "핀란드는 교육이 좋대"], + "k": 1, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "no_key" + # 검색된 스타일 예시가 프롬프트에 반영됐는지는 no_key 응답의 text(프롬프트 + # 원문)로 확인 -- retrieve()가 실제로 호출·반영됐다는 증거. + assert "어제 저녁엔 라면 먹었어" in body["text"] or "핀란드는 교육이 좋대" in body["text"] + + +def test_draft_rejects_both_style_sources(): + resp = client.post( + "/draft", + json={ + "context_lines": ["상대: 안녕"], + "style_examples": ["ㅇㅇ"], + "history": ["ㅇㅇ"], + }, + ) + assert resp.status_code == 422 + + +def test_draft_rejects_neither_style_source(): + resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]}) + assert resp.status_code == 422 diff --git a/ai-service/tests/test_retrieve_style.py b/ai-service/tests/test_retrieve_style.py new file mode 100644 index 0000000..5096acf --- /dev/null +++ b/ai-service/tests/test_retrieve_style.py @@ -0,0 +1,50 @@ +"""Promotes retrieve_style.py's manual verification into pytest -- roadmap.md +Phase 1 §2.5. Covers the recency-vs-overlap bug fixed during PoC work: overlap +must always outrank recency, recency only breaks ties (see retrieve_style.py's +docstring).""" +from app.retrieve_style import _jaccard, retrieve, tokenize + + +def test_tokenize_extracts_hangul_and_alnum_only(): + assert tokenize("핀란드는 교육이 좋대!!") == {"핀란드는", "교육이", "좋대"} + + +def test_jaccard_empty_sets_score_zero(): + assert _jaccard(set(), {"a"}) == 0.0 + assert _jaccard({"a"}, set()) == 0.0 + + +def test_jaccard_identical_sets_score_one(): + assert _jaccard({"a", "b"}, {"a", "b"}) == 1.0 + + +def test_keyword_overlap_outranks_recency(): + # 핀란드 관련 예시가 훨씬 과거에 있어도, 방금 온 무관한 최신 메시지들보다 + # 우선 검색돼야 한다 -- 가중합으로 점수를 매기면 recency가 이를 뒤집는 + # 버그가 있었음 (poc 작업 중 발견/수정). + history = [ + "핀란드는 교육이 진짜 잘 되어있대", + "오늘 저녁 뭐 먹지", + "나 요즘 잠을 못 자", + "어제 넷플릭스 뭐 봤어", + ] + result = retrieve(history, "핀란드는 교육이 좋대", k=1) + assert result == ["핀란드는 교육이 진짜 잘 되어있대"] + + +def test_recency_breaks_ties_among_equal_overlap(): + # Both candidates share exactly one token with the query and have the + # same union size, so their Jaccard overlap ties at 0.25 -- only then + # should recency (the later message) win. + query = "사과 바나나 포도" + older, newer = "사과 딸기", "바나나 수박" + assert _jaccard(tokenize(older), tokenize(query)) == _jaccard(tokenize(newer), tokenize(query)) + + result = retrieve([older, newer], query, k=1) + assert result == [newer] + + +def test_k_limits_result_count(): + history = [f"메시지 {i}" for i in range(10)] + result = retrieve(history, "메시지", k=3) + assert len(result) == 3 diff --git a/core-backend/README.md b/core-backend/README.md index 8d41c52..99f1ee1 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -30,9 +30,10 @@ go test ./... -v ``` `main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket -브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/계정 -삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 -동일한 케이스 + Go에서 새로 추가된 것들). +브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성 +플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/ +계정 삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient +테스트와 동일한 케이스 + Go에서 새로 추가된 것들). ## 지금 있는 것 @@ -63,10 +64,24 @@ go test ./... -v - 사람이 직접 보내는 메시지(`sender_mode` 기본값 `human`)는 이 게이트를 타지 않는다 — 본인이 직접 쓴 말을 막을 이유가 없다. +**2.2/2.5 자율성 엔진(L0~L2) 최소 오케스트레이션** (PRD.md §2.1/§2.2, roadmap.md §2.5 QA 작업 중 +필요해져서 구현) +- `PATCH /users/{id}/twin-settings` — `{"autonomy_level": "L0"|"L1"|"L2"}`로 전역 자율성 레벨 변경 + (가입 시 기본값은 L0, PRD.md §2.1) +- 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: **L0**은 항상 차단(403, 초안만 가능), **L1**은 + 요청에 `approved: true`가 없으면 차단(403), **L2**는 `whitelist_rules`에 매칭되는 주제면 + `approved` 없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요 +- 화이트리스트 매칭(`whitelistMatches`)은 v1 최소 구현 — 유저의 모든 `WhitelistRule.TopicKeyword`를 + 메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 아직 무시함 — + 대화방↔연락처 연결이 아직 모델링되어 있지 않아서(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) +- 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved: + true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함) + ## 아직 없는 것 (다음 워크스트림) -- 자율성 엔진(L0~L2) 오케스트레이션 전체 흐름 (`roadmap.md` §2.2) — 지금은 발송 시점 하드게이트만 - 있고, 초안 생성→승인 대기→자동발송 분기의 나머지는 아직 +- 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로 + 등록하는 API는 아직 없음, 2.3 자율성 설정 화면과 같이 설계 필요) +- 상대별(`ContactID`) 화이트리스트/자율성 예외 (지금은 전역 레벨만 지원) - 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임) - 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) - 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 diff --git a/core-backend/main.go b/core-backend/main.go index 7c3f856..4090689 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -3,6 +3,7 @@ package main import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -22,6 +23,14 @@ type sendMessageRequest struct { SenderID uint `json:"sender_id" binding:"required"` Text string `json:"text" binding:"required"` SenderMode SenderMode `json:"sender_mode"` + // Approved represents the human tapping "승인" on an L1 draft, or on an + // L2 draft outside the whitelist (PRD.md §2.2). Ignored for + // human-authored messages. + Approved bool `json:"approved"` +} + +type updateTwinSettingsRequest struct { + AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"` } type draftMessageRequest struct { @@ -88,7 +97,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // so no client or upstream path can bypass it (AGENTS.md absolute // safety invariants). Human-authored messages are the human's own // words and are never gated. On any doubt (AI service unreachable - // or erroring) we fail closed and block the send. + // or erroring) we fail closed and block the send. Escalation is + // checked before autonomy level, and applies regardless of level or + // whitelist match -- L2 auto-send never overrides it. if req.SenderMode == SenderTwin { result, err := ai.checkEscalation(req.Text) if err != nil { @@ -105,6 +116,33 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason}) return } + + // Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing + // settings fail closed to L0, the documented default. + level := AutonomyL0 + var settings TwinSettings + if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil { + level = settings.AutonomyLevel + } + + switch level { + case AutonomyL0: + c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) + return + case AutonomyL1: + if !req.Approved { + c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) + return + } + case AutonomyL2: + if !req.Approved && !whitelistMatches(db, req.SenderID, req.Text) { + c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) + return + } + default: + c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) + return + } } message := Message{ @@ -168,6 +206,36 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) }) + r.PATCH("/users/:id/twin-settings", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req updateTwinSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if req.AutonomyLevel != AutonomyL0 && req.AutonomyLevel != AutonomyL1 && req.AutonomyLevel != AutonomyL2 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"}) + return + } + + var settings TwinSettings + if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "twin settings not found for user"}) + return + } + settings.AutonomyLevel = req.AutonomyLevel + if err := db.Save(&settings).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel}) + }) + r.DELETE("/users/:id", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { @@ -247,6 +315,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return r } +// whitelistMatches is a v1-minimal check: any of the user's WhitelistRule +// keywords appearing as a substring of the message text counts as a match. +// It intentionally ignores WhitelistRule.ContactID (per-counterpart +// whitelisting) because conversations aren't yet linked to a Contact row -- +// that link needs its own design pass once the client's contact model +// exists, so this only supports the "any counterpart" case for now. +func whitelistMatches(db *gorm.DB, userID uint, text string) bool { + var rules []WhitelistRule + db.Where("user_id = ?", userID).Find(&rules) + for _, rule := range rules { + if strings.Contains(text, rule.TopicKeyword) { + return true + } + } + return false +} + func parseUintParam(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index fa16c61..af6ece5 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -88,6 +88,20 @@ func postJSON(t *testing.T, url string, body interface{}) *http.Response { return resp } +func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) { + t.Helper() + body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level}) + req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("set autonomy level: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting autonomy level, got %d", resp.StatusCode) + } +} + func TestHealth(t *testing.T) { server, _ := setupTestServer(t) resp, err := http.Get(server.URL + "/health") @@ -139,6 +153,8 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { t.Fatalf("create conversation: %v", err) } + setAutonomyLevel(t, server.URL, senderID, AutonomyL1) + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) @@ -151,6 +167,7 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { SenderID: senderID, Text: "안녕하세요", SenderMode: SenderTwin, + Approved: true, }) if sendResp.StatusCode != http.StatusOK { t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode) @@ -213,7 +230,11 @@ func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) { } } -func TestTwinMessageNotEscalatedIsSent(t *testing.T) { +// The four tests below cover PRD.md §2.1/§2.2's L0->L1->L2 autonomy flow: +// L0 (기본값) never auto-sends, L1 requires explicit approval, L2 auto-sends +// only for whitelisted topics and otherwise behaves like L1. + +func TestTwinSendBlockedAtDefaultL0(t *testing.T) { server, db := setupTestServer(t) signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin2", DisplayName: "하늘"}) @@ -226,19 +247,143 @@ func TestTwinMessageNotEscalatedIsSent(t *testing.T) { t.Fatalf("create conversation: %v", err) } + // New users default to L0 (PRD.md §2.1) -- a non-escalating twin send + // must still be blocked, since L0 means no auto-send at all. resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, }) - if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200 for non-escalated twin send, got %d", resp.StatusCode) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 blocking twin auto-send at L0, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("L0 must never persist a twin auto-send, found %d rows", count) + } +} + +func TestTwinSendRequiresApprovalAtL1(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l1", DisplayName: "서준"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + setAutonomyLevel(t, server.URL, senderID, AutonomyL1) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages" + + unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin}) + if unapproved.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 without approval at L1, got %d", unapproved.StatusCode) + } + + approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true}) + if approved.StatusCode != http.StatusOK { + t.Fatalf("expected 200 with approval at L1, got %d", approved.StatusCode) } var count int64 db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) if count != 1 { - t.Fatalf("expected 1 persisted message, got %d", count) + t.Fatalf("expected exactly 1 persisted message (the approved one), got %d", count) + } +} + +func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-wl", DisplayName: "가은"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + // No approved:true -- L2 + whitelist match should auto-send without it. + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 auto-sending whitelisted topic at L2, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 1 { + t.Fatalf("expected 1 auto-sent message, got %d", count) + } +} + +func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-nowl", DisplayName: "도윤"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages" + + // Text doesn't match any whitelist keyword -- L2 falls back to L1 + // behavior (approval required), it does not just allow or just block. + unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin}) + if unapproved.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for non-whitelisted topic at L2 without approval, got %d", unapproved.StatusCode) + } + + approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin, Approved: true}) + if approved.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for non-whitelisted topic at L2 with approval, got %d", approved.StatusCode) + } +} + +func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) { + server, db := setupTestServer(t) + + signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-esc", DisplayName: "은서"}) + var user map[string]interface{} + json.NewDecoder(signupResp.Body).Decode(&user) + senderID := uint(user["id"].(float64)) + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + // Whitelisted keyword happens to be the same word that also triggers + // the money escalation pattern in the mock AI service. + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403: escalation must override L2 whitelist match and approved:true, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("escalated message must never be sent regardless of level/whitelist/approval, found %d rows", count) } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 41a45bc..62849c5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -49,9 +49,14 @@ - [x] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) — `core-backend/aiservice.go`(`AIServiceClient.requestDraft`) + `POST /conversations/:id/draft` 라우트, mock AI 서비스로 정상 프록시·404·400(스타일 소스 없음) 전부 실제 테스트로 확인함 -- [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 - (`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디 - 책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음) +- [x] 자율성 엔진(L0~L2) 오케스트레이션 최소 버전 — 2.5 QA에서 "자율성 플로우 통합 테스트"를 쓰려면 + 실제 분기 로직이 있어야 해서 그때 구현함. 결정: 에스컬레이션 하드게이트(Go 코어, 이미 구현) → + 레벨 확인은 Go 코어 책임(`PATCH /users/:id/twin-settings`로 레벨 변경, 메시지 저장 시 레벨별 분기). + L0 항상 차단, L1은 `approved:true` 필요, L2는 화이트리스트 매칭 시 즉시 자동발송·매칭 없으면 L1과 + 동일. 에스컬레이션은 레벨/화이트리스트 무관 항상 우선. **간소화한 부분**: 검색(retrieve)→초안 + 생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는 + `ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서 + (Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고) - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 - [ ] 사후 알림 + 되돌리기 로그 스키마/API @@ -77,9 +82,16 @@ 없음)에서는 진행 불가 — 2.3과 함께 로컬 환경 대기 **2.5 QA/테스트** -- [ ] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 동일하게 -- [ ] 자율성 플로우(L0→L1→L2) 통합 테스트 -- [ ] 온보딩·채팅·설정 수동 QA +- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 + 동일하게 — `ai-service/tests/`(pytest, 34개), SELFTEST_CASES 승격 + Gemini 호출 mock + `/health`· + `/escalate/check`·`/draft` FastAPI 엔드포인트 테스트까지 포함. `poc/tone-corpus/`의 ad-hoc + `--selftest`는 실험 도구로 그대로 두고(승격 대상은 "실제 서비스"인 `ai-service/`), 별개로 유지 +- [x] 자율성 플로우(L0→L1→L2) 통합 테스트 — `core-backend/main_test.go`. 위 2.2 최소 오케스트레이션 + 구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인 + 필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스 + 전부 실제 테스트로 확인함 +- [ ] 온보딩·채팅·설정 수동 QA — Flutter 클라이언트가 없어 이 환경에서는 불가, 2.3과 함께 로컬 + 환경 대기 **2.6 베타 배포 준비** - [ ] 초대 기반 베타 가입 플로우 @@ -108,14 +120,15 @@ 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, - `POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스 - 말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는 - 여전히 로컬 환경 대기 중 + `POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로 + 완료(아래 5번과 함께 구현). 남은 건 온디바이스 말투 이력 저장, 사후 알림/되돌리기 로그 — 클라이언트 + 의존이라 2.3(Flutter) 대기 4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 -5. [~] 2.4/2.5 안전장치·QA — 2.4의 서버 쪽 절반(하드게이트 우회 차단, 삭제 플로우) 완료. 나머지는 - 클라이언트 쪽(온디바이스 암호화·데이터 흐름 대시보드, Flutter 대기)과 2.5 QA(테스트 스위트 승격, - 자율성 플로우 통합 테스트) — 다음 작업 +5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 삭제 플로우. 2.5: `ai-service` + pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 로직이 필요해서 + 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽(온디바이스 암호화· + 데이터 흐름 대시보드·수동 QA) — Flutter 대기 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 From f79108b9cda9cae502ce930e9a37047e79bf6e03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:51:15 +0000 Subject: [PATCH 31/33] =?UTF-8?q?2.6=20=EB=B2=A0=ED=83=80=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=A4=80=EB=B9=84:=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EC=BD=94=EB=93=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20+=20=EC=9A=B4=EC=98=81=20=EC=A7=80=ED=91=9C=20+=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=EA=B6=8C=20=EC=95=88=EC=A0=84=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발견한 문제(2.6 진행 중): 거부권(peer veto) 안전 불변식이 코드에 전혀 구현되어 있지 않았음. Contact.TwinDisabledByPeer는 스키마에만 있고 어디서도 읽거나 쓰지 않았고, tech-design.md §4는 "대화방 단위 플래그" 라는데 실제로는 상대(Contact) 단위로 모델링돼 있어 설계 문서와도 불일치. Conversation.TwinDisabledByPeer로 옮기고 POST /conversations/:id/veto 추가, 메시지 발송 시 거부권 -> 에스컬레이션 -> 자율성 레벨 순으로 체크(앞 단계가 뒤 단계를 항상 이김)하도록 수정. 2.6 본작업: - 초대 기반 가입: 기존엔 아무 문자열이나 처음 쓰면 통과돼서 실제로는 초대 기반이 아니었음. InviteCode 테이블 + POST /invites(발급) 추가하고 /auth/signup이 미리 발급된 미사용 코드인지 검증하도록 변경 (모르는 코드 400, 이미 쓴 코드 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움 - GET /admin/metrics 추가 -- 메시지 수(휴먼/트윈), 에스컬레이션 사유별 집계, 거부권 발동률(vision.md 거부율 지표의 1차 근사), 초대 코드 발급/사용 수. 생성 지연시간·오류율은 별도 계측 계층이 없어 넣지 않고 문서에 명시 실제 베타 오픈 시점 자체는 roadmap.md §3(PoC 결과 필요)이 끝나야 정할 수 있어서 여전히 보류 -- 이번엔 서버 인프라만 준비함 --- core-backend/README.md | 44 ++++++-- core-backend/main.go | 133 ++++++++++++++++++++++-- core-backend/main_test.go | 211 ++++++++++++++++++++++++++++++-------- core-backend/models.go | 42 +++++--- docs/roadmap.md | 42 ++++++-- 5 files changed, 389 insertions(+), 83 deletions(-) diff --git a/core-backend/README.md b/core-backend/README.md index 99f1ee1..a5fb016 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,34 +29,45 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 go test ./... -v ``` -`main_test.go`가 가입/중복코드 거부(409)/메시지 저장/존재하지 않는 대화방(404)/WebSocket +`main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket 브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성 플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/ -계정 삭제까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 (`../backend/`의 Python TestClient -테스트와 동일한 케이스 + Go에서 새로 추가된 것들). +거부권(peer veto, 다른 모든 조건보다 우선)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 +돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). ## 지금 있는 것 **2.1 코어 백엔드** - `GET /health` — 헬스체크 -- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409) +- `POST /auth/signup` — 초대 코드 기반 가입. 미리 발급된(`POST /invites`) 미사용 코드가 아니면 + 400, 이미 쓴 코드면 409 (아래 2.6) - `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트. - `sender_mode: "twin"`인 요청은 저장 전에 반드시 에스컬레이션 하드게이트를 통과해야 한다 (아래 2.4) + `sender_mode: "twin"`인 요청은 저장 전에 반드시 거부권·에스컬레이션 하드게이트를 통과해야 한다 + (아래 2.4) +- `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다. + 이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이 + 전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음) - `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) - `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트· - 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제 (`tech-design.md` §5 - "사용자가 언제든 초기화 가능") -- DB 모델 (`models.go`): `users`, `contacts`, `conversations`, `conversation_participants`, - `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs` — `../backend/app/models.py`와 - 동일한 스키마 + 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴 + 초대 코드는 "사용됨" 상태는 유지한 채 유저 참조만 지운다 (`tech-design.md` §5 "사용자가 + 언제든 초기화 가능") +- DB 모델 (`models.go`): `users`, `invite_codes`, `contacts`, `conversations`, + `conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs` — + `../backend/app/models.py`와 동일한 스키마(+ `invite_codes`는 여기서 새로 추가) **2.2 AI 서비스 연동** - `POST /conversations/{id}/draft` — `ai-service/`의 `POST /draft`를 호출해 초안을 프록시 반환 (`style_examples`/`history` 중 하나 필수, 없으면 400) **2.4 안전장치 통합** +- 트윈 발송 시 체크 순서: **거부권 → 에스컬레이션 → 자율성 레벨**. 앞 단계에서 막히면 뒤 단계는 + 아예 확인하지 않는다 — 순서가 바뀌면 안 되는 이유는 거부권/에스컬레이션이 레벨·화이트리스트· + 승인 여부보다 항상 우선해야 하기 때문 (테스트로 확인함) +- 거부권: `conversation.TwinDisabledByPeer`가 켜져 있으면 그 자리에서 403 — AI 서비스 호출조차 + 하지 않는다. 사람이 직접 보내는 메시지는 영향받지 않는다 - `AIServiceClient.checkEscalation` (`aiservice.go`) — `ai-service`의 `POST /escalate/check` 호출. - `/conversations/{id}/messages`가 `sender_mode: "twin"`을 받을 때마다 이걸 먼저 호출해서, 어떤 + `/conversations/{id}/messages`가 `sender_mode: "twin"`을 받을 때마다 이걸 호출해서, 어떤 경로로 왔든(그리고 `/draft`를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는 하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고 `escalation_logs`에 기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를 @@ -77,6 +88,15 @@ go test ./... -v - 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved: true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함) +**2.6 베타 배포 준비** +- `POST /invites` — 새 초대 코드 발급(무작위 10자 hex). 아직 발급자 인증이 없어 누구나 호출 + 가능 — 인증/세션이 생기기 전까지는 서버 콘솔·내부 도구에서만 호출한다고 가정 +- `GET /admin/metrics` — `users_total`·`messages_human_total`·`messages_twin_total`· + `escalations_total`·`escalations_by_reason`·`conversations_total`·`conversations_vetoed`· + `peer_veto_rate`·`invites_minted`·`invites_used`. 지금 스키마로 정직하게 계산 가능한 것만 — + `peer_veto_rate`는 vision.md "분신 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님. + 생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것") + ## 아직 없는 것 (다음 워크스트림) - 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로 @@ -86,6 +106,8 @@ go test ./... -v - 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) - 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 로컬 환경에서 진행) +- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음) +- `/invites`·`/admin/metrics` 접근 제어 (지금은 인증이 없어 누구나 호출 가능 — 아래 인증 항목과 같이 해결) - 푸시 알림 연동 - 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음) - 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입) diff --git a/core-backend/main.go b/core-backend/main.go index 4090689..e4186fc 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -1,9 +1,12 @@ package main import ( + "crypto/rand" + "encoding/hex" "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -47,6 +50,70 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + r.POST("/invites", func(c *gin.Context) { + code, err := generateInviteCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + invite := InviteCode{Code: code} + if err := db.Create(&invite).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": invite.Code}) + }) + + r.GET("/admin/metrics", func(c *gin.Context) { + // v1-minimal (roadmap.md §2.6): only counts honestly derivable from + // the current schema. Draft-generation latency and AI-service error + // rate need a request-timing/logging layer that doesn't exist yet -- + // not fabricated here, left for that future work. + var usersTotal, humanMessages, twinMessages, escalationsTotal int64 + var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64 + db.Model(&User{}).Count(&usersTotal) + db.Model(&Message{}).Where("sender_mode = ?", SenderHuman).Count(&humanMessages) + db.Model(&Message{}).Where("sender_mode = ?", SenderTwin).Count(&twinMessages) + db.Model(&EscalationLog{}).Count(&escalationsTotal) + db.Model(&Conversation{}).Count(&conversationsTotal) + db.Model(&Conversation{}).Where("twin_disabled_by_peer = ?", true).Count(&conversationsVetoed) + db.Model(&InviteCode{}).Count(&invitesMinted) + db.Model(&InviteCode{}).Where("used_at IS NOT NULL").Count(&invitesUsed) + + type reasonCount struct { + Reason string + Count int64 + } + var reasonCounts []reasonCount + db.Model(&EscalationLog{}).Select("reason, count(*) as count").Group("reason").Scan(&reasonCounts) + escalationsByReason := map[string]int64{} + for _, rc := range reasonCounts { + escalationsByReason[rc.Reason] = rc.Count + } + + var peerVetoRate float64 + if conversationsTotal > 0 { + peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) + } + + c.JSON(http.StatusOK, gin.H{ + "users_total": usersTotal, + "messages_human_total": humanMessages, + "messages_twin_total": twinMessages, + "escalations_total": escalationsTotal, + "escalations_by_reason": escalationsByReason, + "conversations_total": conversationsTotal, + "conversations_vetoed": conversationsVetoed, + // Approximates vision.md's "분신 거부율" metric at conversation + // granularity (vetoed conversations / all conversations) -- + // vision.md doesn't pin down the exact denominator, so treat + // this as a first approximation, not the final definition. + "peer_veto_rate": peerVetoRate, + "invites_minted": invitesMinted, + "invites_used": invitesUsed, + }) + }) + r.POST("/auth/signup", func(c *gin.Context) { var req signupRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -54,9 +121,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } - var existing User - if err := db.Where("invite_code = ?", req.InviteCode).First(&existing).Error; err == nil { - c.JSON(http.StatusConflict, gin.H{"detail": "invite_code already used"}) + // 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용 + // 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전 + // 방식은 "초대 기반"이 아니었음. + var invite InviteCode + if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) + return + } + if invite.UsedAt != nil { + c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) return } @@ -67,6 +141,11 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) + now := time.Now() + invite.UsedAt = &now + invite.UsedByUserID = &user.ID + db.Save(&invite) + c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName}) }) @@ -97,10 +176,16 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // so no client or upstream path can bypass it (AGENTS.md absolute // safety invariants). Human-authored messages are the human's own // words and are never gated. On any doubt (AI service unreachable - // or erroring) we fail closed and block the send. Escalation is - // checked before autonomy level, and applies regardless of level or - // whitelist match -- L2 auto-send never overrides it. + // or erroring) we fail closed and block the send. Peer veto is + // checked first (it's a total kill switch for this conversation, + // independent of content), then escalation, then autonomy level -- + // none of the later checks can override an earlier block. if req.SenderMode == SenderTwin { + if conversation.TwinDisabledByPeer { + c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) + return + } + result, err := ai.checkEscalation(req.Text) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) @@ -206,6 +291,31 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) }) + r.POST("/conversations/:id/veto", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + // Peer veto (PRD.md §3.1, tech-design.md §4, AGENTS.md absolute + // safety invariants): the counterpart asked to talk to the human + // only. One-way for v1 -- no "un-veto" endpoint, matching the + // PRD's "즉시 중단" wording; nothing in scope calls for reversing it. + conversation.TwinDisabledByPeer = true + if err := db.Save(&conversation).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true}) + }) + r.PATCH("/users/:id/twin-settings", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { @@ -254,6 +364,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // a minimal relay copy, so deleting it here is not a partial erasure. var messagesDeleted, escalationLogsDeleted int64 err := db.Transaction(func(tx *gorm.DB) error { + if res := tx.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil { + return res.Error + } if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil { return res.Error } @@ -332,6 +445,14 @@ func whitelistMatches(db *gorm.DB, userID uint, text string) bool { return false } +func generateInviteCode() (string, error) { + buf := make([]byte, 5) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + func parseUintParam(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index af6ece5..3e56a5e 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -88,6 +88,28 @@ func postJSON(t *testing.T, url string, body interface{}) *http.Response { return resp } +func mintInvite(t *testing.T, serverURL string) string { + t.Helper() + resp := postJSON(t, serverURL+"/invites", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + return out["code"].(string) +} + +func mustSignup(t *testing.T, serverURL, displayName string) uint { + t.Helper() + resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 signing up %s, got %d", displayName, resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + return uint(out["id"].(float64)) +} + func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) { t.Helper() body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level}) @@ -115,8 +137,9 @@ func TestHealth(t *testing.T) { func TestSignupAndDuplicateRejected(t *testing.T) { server, _ := setupTestServer(t) + code := mintInvite(t, server.URL) - resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "지우"}) + resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "지우"}) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } @@ -126,9 +149,26 @@ func TestSignupAndDuplicateRejected(t *testing.T) { t.Fatalf("unexpected body: %v", out) } - dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "abc123", DisplayName: "dup"}) + dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "dup"}) if dup.StatusCode != http.StatusConflict { - t.Fatalf("expected 409 for duplicate invite_code, got %d", dup.StatusCode) + t.Fatalf("expected 409 for a reused invite code, got %d", dup.StatusCode) + } +} + +func TestSignupRejectsUnknownInviteCode(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "never-minted", DisplayName: "누구"}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for an invite code nobody minted, got %d", resp.StatusCode) + } +} + +func TestMintInviteReturnsUniqueCodes(t *testing.T) { + server, _ := setupTestServer(t) + a := mintInvite(t, server.URL) + b := mintInvite(t, server.URL) + if a == b { + t.Fatalf("expected distinct invite codes, got %q twice", a) } } @@ -143,10 +183,7 @@ func TestSendMessageToMissingConversation(t *testing.T) { func TestSendMessageAndWebSocketBroadcast(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "sender1", DisplayName: "정우"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "정우") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -185,10 +222,7 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin1", DisplayName: "민지"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "민지") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -237,10 +271,7 @@ func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) { func TestTwinSendBlockedAtDefaultL0(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin2", DisplayName: "하늘"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "하늘") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -268,10 +299,7 @@ func TestTwinSendBlockedAtDefaultL0(t *testing.T) { func TestTwinSendRequiresApprovalAtL1(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l1", DisplayName: "서준"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "서준") setAutonomyLevel(t, server.URL, senderID, AutonomyL1) conv := Conversation{IsGroup: false} @@ -300,10 +328,7 @@ func TestTwinSendRequiresApprovalAtL1(t *testing.T) { func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-wl", DisplayName: "가은"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "가은") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) @@ -330,10 +355,7 @@ func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) { func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-nowl", DisplayName: "도윤"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "도윤") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) @@ -359,10 +381,7 @@ func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) { func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin-l2-esc", DisplayName: "은서"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "은서") setAutonomyLevel(t, server.URL, senderID, AutonomyL2) // Whitelisted keyword happens to be the same word that also triggers // the money escalation pattern in the mock AI service. @@ -390,10 +409,7 @@ func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) { func TestHumanMessageBypassesEscalationGate(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "human1", DisplayName: "재민"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "재민") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -431,10 +447,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) { server := httptest.NewServer(router) t.Cleanup(server.Close) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "twin3", DisplayName: "소연"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - senderID := uint(user["id"].(float64)) + senderID := mustSignup(t, server.URL, "소연") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -460,10 +473,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) { func TestDeleteUserPurgesData(t *testing.T) { server, db := setupTestServer(t) - signupResp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "erase1", DisplayName: "유나"}) - var user map[string]interface{} - json.NewDecoder(signupResp.Body).Decode(&user) - userID := uint(user["id"].(float64)) + userID := mustSignup(t, server.URL, "유나") conv := Conversation{IsGroup: false} if err := db.Create(&conv).Error; err != nil { @@ -514,6 +524,121 @@ func TestDeleteUserPurgesData(t *testing.T) { } } +func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "지민") + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + vetoResp := postJSON(t, convBase+"/veto", nil) + if vetoResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 triggering veto, got %d", vetoResp.StatusCode) + } + + // Would otherwise auto-send (L2 + whitelist match + approved), but the + // veto must override every other check. + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403: peer veto must override L2 whitelist + approval, got %d", resp.StatusCode) + } + + var count int64 + db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count) + if count != 0 { + t.Fatalf("no twin message should be sent after veto, found %d rows", count) + } +} + +func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "다은") + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + postJSON(t, convBase+"/veto", nil) + + resp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("veto must not block the human's own messages, got %d", resp.StatusCode) + } +} + +func TestVetoMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/veto", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { + server, db := setupTestServer(t) + + senderID := mustSignup(t, server.URL, "메트릭") + setAutonomyLevel(t, server.URL, senderID, AutonomyL1) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman}) + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true}) + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin}) + + conv2 := Conversation{IsGroup: false} + db.Create(&conv2) + postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil) + + resp, err := http.Get(server.URL + "/admin/metrics") + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + + if out["messages_human_total"].(float64) != 1 { + t.Fatalf("expected 1 human message, got %v", out["messages_human_total"]) + } + if out["messages_twin_total"].(float64) != 1 { + t.Fatalf("expected 1 twin message, got %v", out["messages_twin_total"]) + } + if out["escalations_total"].(float64) != 1 { + t.Fatalf("expected 1 escalation, got %v", out["escalations_total"]) + } + byReason := out["escalations_by_reason"].(map[string]interface{}) + if byReason["금전"].(float64) != 1 { + t.Fatalf("expected 1 금전 escalation, got %v", byReason) + } + if out["conversations_total"].(float64) != 2 || out["conversations_vetoed"].(float64) != 1 { + t.Fatalf("expected 2 conversations, 1 vetoed, got %v", out) + } + if out["peer_veto_rate"].(float64) != 0.5 { + t.Fatalf("expected 0.5 veto rate, got %v", out["peer_veto_rate"]) + } + if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 { + t.Fatalf("expected at least 1 minted/used invite, got %v", out) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/core-backend/models.go b/core-backend/models.go index 10b97c2..3a70df8 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -24,22 +24,37 @@ type User struct { CreatedAt time.Time } -// Contact carries per-peer state like the veto flag (tech-design.md §4: -// twin_disabled_by_peer), independent of any one conversation. -type Contact struct { - ID uint `gorm:"primaryKey"` - OwnerUserID uint `gorm:"not null;index"` - ContactUserID *uint - DisplayName string `gorm:"not null"` - RelationshipNote string - TwinDisabledByPeer bool `gorm:"not null;default:false"` - CreatedAt time.Time +// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6 +// "초대 기반 베타 가입 플로우") -- signup validates against this table instead +// of just deduping User.InviteCode, so joining actually requires a code +// someone handed out, not any never-used string. +type InviteCode struct { + ID uint `gorm:"primaryKey"` + Code string `gorm:"uniqueIndex;not null"` + CreatedAt time.Time + UsedAt *time.Time + UsedByUserID *uint } +type Contact struct { + ID uint `gorm:"primaryKey"` + OwnerUserID uint `gorm:"not null;index"` + ContactUserID *uint + DisplayName string `gorm:"not null"` + RelationshipNote string + CreatedAt time.Time +} + +// TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위 +// 플래그(twin_disabled_by_peer)") -- per conversation, not per contact, so +// it lives here rather than on Contact. Set by POST /conversations/:id/veto +// when the counterpart asks to talk to the human only; checked before any +// twin auto-send in that conversation (main.go). type Conversation struct { - ID uint `gorm:"primaryKey"` - IsGroup bool `gorm:"not null;default:false"` - CreatedAt time.Time + ID uint `gorm:"primaryKey"` + IsGroup bool `gorm:"not null;default:false"` + TwinDisabledByPeer bool `gorm:"not null;default:false"` + CreatedAt time.Time } type ConversationParticipant struct { @@ -91,6 +106,7 @@ type EscalationLog struct { var allModels = []interface{}{ &User{}, + &InviteCode{}, &Contact{}, &Conversation{}, &ConversationParticipant{}, diff --git a/docs/roadmap.md b/docs/roadmap.md index 62849c5..607f042 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,8 +36,9 @@ - [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함 - [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함. 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 -- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules - — `core-backend/models.go` (GORM), `backend/app/models.py`(Python 프로토타입)와 동일 스키마 +- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings, + escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py` + (Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가) - [ ] 푸시 알림 서비스 연동 `backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. @@ -76,6 +77,13 @@ 실제로 일어나는 단 하나의 지점(메시지 저장)에서 걸리므로 클라이언트가 뭘 하든 우회 불가. AI 서비스 응답 불가 시 fail-safe(발송 차단, 502)로 처리. 사람이 직접 보내는 메시지는 게이트 대상이 아님. 차단·통과·게이트 불능 3케이스 전부 실제 테스트로 확인함 (`core-backend/main_test.go`) +- [x] **발견 및 수정**: 거부권(peer veto) 안전 불변식이 코드에 전혀 구현되어 있지 않았음 — + `Contact.TwinDisabledByPeer` 필드만 스키마에 있고 어디서도 읽거나 쓰지 않았고, `tech-design.md` + §4는 "대화방 단위 플래그"라는데 실제로는 상대(Contact) 단위로 모델링돼 있어 설계 문서와도 + 불일치했음(2.6 작업 중 발견). `Conversation.TwinDisabledByPeer`로 옮기고 `POST + /conversations/:id/veto` 추가, 메시지 발송 시 **거부권 → 에스컬레이션 → 자율성 레벨** 순으로 + 체크(거부권이 전부보다 우선) — L2 화이트리스트 매칭 + `approved:true`여도 거부권이 켜져 있으면 + 무조건 차단되는 것까지 테스트로 확인함 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK @@ -94,9 +102,20 @@ 환경 대기 **2.6 베타 배포 준비** -- [ ] 초대 기반 베타 가입 플로우 -- [ ] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 -- [ ] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) +- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 + 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 + `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 + 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 + 인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) +- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 + `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 + 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 + 보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 +- [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics`로 + 카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는 + 노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고, + 지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지 + 않고 명시적으로 비워둠) #### 3. PoC 결과가 있어야 정할 수 있는 것 (맨 마지막 — 그 전엔 손대지 않는다) @@ -125,11 +144,14 @@ 의존이라 2.3(Flutter) 대기 4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 -5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 삭제 플로우. 2.5: `ai-service` - pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 로직이 필요해서 - 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽(온디바이스 암호화· - 데이터 흐름 대시보드·수동 QA) — Flutter 대기 -6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈 +5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: + `ai-service` pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 + 로직이 필요해서 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽 + (온디바이스 암호화·데이터 흐름 대시보드·수동 QA) — Flutter 대기 + - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST + /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 + 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 +6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 §3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. From a7410011971aa688de996748ad6404ae0ecaec38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:01:06 +0000 Subject: [PATCH 32/33] =?UTF-8?q?=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B7=9C=EC=B9=99=20CRUD=20API=20+=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0(undo)=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화이트리스트 CRUD: - POST/GET /users/:id/whitelist-rules, DELETE /users/:id/whitelist-rules/:ruleId - Flutter의 자율성 설정 화면(roadmap.md 2.3)이 바로 붙여 쓸 수 있게 준비. contact_id는 저장되지만 매칭 로직(whitelistMatches)은 아직 전역 키워드만 봄 -- 대화방-연락처 연결 모델링 필요(기존에 문서화된 한계, 그대로 유지) 되돌리기(one-tap undo, AGENTS.md 안전 불변식): - Message.Retracted 필드 추가 - POST /messages/:id/retract -- 트윈이 자동발송한 메시지만 대상(사람이 쓴 메시지는 400), 이미 되돌린 건 409 - 성공 시 같은 대화방 WebSocket에 {"type":"retraction", "id":...} 브로드캐스트. 일반 메시지 브로드캐스트도 {"type":"message"}를 붙여서 클라이언트가 두 이벤트를 구분할 수 있게 함 되돌리기 버튼/사후알림 UI 자체는 여전히 Flutter 쪽 몫으로 남아있음 --- core-backend/README.md | 28 +++++-- core-backend/main.go | 122 +++++++++++++++++++++++++++++++ core-backend/main_test.go | 149 ++++++++++++++++++++++++++++++++++++++ core-backend/models.go | 7 +- docs/roadmap.md | 15 +++- 5 files changed, 309 insertions(+), 12 deletions(-) diff --git a/core-backend/README.md b/core-backend/README.md index a5fb016..0ada203 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -32,8 +32,9 @@ go test ./... -v `main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket 브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성 플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/ -거부권(peer veto, 다른 모든 조건보다 우선)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 -돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). +거부권(peer veto, 다른 모든 조건보다 우선)/화이트리스트 규칙 CRUD/메시지 되돌리기(twin 전용, WebSocket +브로드캐스트 포함)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 +(`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). ## 지금 있는 것 @@ -47,6 +48,11 @@ go test ./... -v - `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다. 이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이 전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음) +- `POST /messages/{id}/retract` — 되돌리기(one-tap undo, PRD.md §3.1): **트윈이 자동발송한 + 메시지만** 대상. 사람이 직접 쓴 메시지는 400. 이미 되돌린 메시지를 다시 호출하면 409. 성공하면 + `Message.Retracted`를 켜고 같은 대화방의 WebSocket 연결에 `{"type": "retraction", "id": ...}`를 + 브로드캐스트한다 (아래 참고: 일반 메시지 브로드캐스트도 이제 `"type": "message"`를 포함해서 + 클라이언트가 두 이벤트를 구분할 수 있게 함) - `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) - `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트· 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴 @@ -79,12 +85,16 @@ go test ./... -v 필요해져서 구현) - `PATCH /users/{id}/twin-settings` — `{"autonomy_level": "L0"|"L1"|"L2"}`로 전역 자율성 레벨 변경 (가입 시 기본값은 L0, PRD.md §2.1) +- `POST`/`GET /users/{id}/whitelist-rules`, `DELETE /users/{id}/whitelist-rules/{ruleId}` — + 화이트리스트 규칙 CRUD (`{"topic_keyword": "...", "contact_id": null|uint}`). `contact_id`는 + 저장은 되지만 아직 매칭에는 안 쓰인다 — 바로 아래 참고 - 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: **L0**은 항상 차단(403, 초안만 가능), **L1**은 요청에 `approved: true`가 없으면 차단(403), **L2**는 `whitelist_rules`에 매칭되는 주제면 `approved` 없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요 - 화이트리스트 매칭(`whitelistMatches`)은 v1 최소 구현 — 유저의 모든 `WhitelistRule.TopicKeyword`를 - 메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 아직 무시함 — - 대화방↔연락처 연결이 아직 모델링되어 있지 않아서(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) + 메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 CRUD로 + 저장은 되지만 매칭 로직에서는 아직 무시함 — 대화방↔연락처 연결이 아직 모델링되어 있지 않아서 + (클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) - 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved: true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함) @@ -99,11 +109,13 @@ go test ./... -v ## 아직 없는 것 (다음 워크스트림) -- 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로 - 등록하는 API는 아직 없음, 2.3 자율성 설정 화면과 같이 설계 필요) -- 상대별(`ContactID`) 화이트리스트/자율성 예외 (지금은 전역 레벨만 지원) +- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은 + 아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요) +- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 + 건 Flutter 쪽) +- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던 + 목록"을 조회하는 API는 아직 없음 — 필요해지면 추가) - 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임) -- 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) - 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 로컬 환경에서 진행) - 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음) diff --git a/core-backend/main.go b/core-backend/main.go index e4186fc..d503539 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -36,6 +36,11 @@ type updateTwinSettingsRequest struct { AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"` } +type createWhitelistRuleRequest struct { + ContactID *uint `json:"contact_id"` + TopicKeyword string `json:"topic_keyword" binding:"required"` +} + type draftMessageRequest struct { ContextLines []string `json:"context_lines" binding:"required"` StyleExamples []string `json:"style_examples"` @@ -242,6 +247,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } relay.broadcast(convID, gin.H{ + "type": "message", "id": message.ID, "sender_id": message.SenderID, "sender_mode": message.SenderMode, @@ -251,6 +257,44 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"id": message.ID}) }) + r.POST("/messages/:id/retract", func(c *gin.Context) { + msgID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var message Message + if err := db.First(&message, msgID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"}) + return + } + // One-tap undo (PRD.md §3.1, AGENTS.md "every automatic action needs + // post-hoc notification + one-tap undo") applies to unattended + // twin auto-sends -- a human retracting their own words is a + // different, unrelated feature this endpoint doesn't cover. + if message.SenderMode != SenderTwin { + c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored (auto-sent) messages can be retracted"}) + return + } + if message.Retracted { + c.JSON(http.StatusConflict, gin.H{"detail": "message already retracted"}) + return + } + + message.Retracted = true + if err := db.Save(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + relay.broadcast(message.ConversationID, gin.H{ + "type": "retraction", + "id": message.ID, + }) + + c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true}) + }) + r.POST("/conversations/:id/draft", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { @@ -346,6 +390,84 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel}) }) + r.POST("/users/:id/whitelist-rules", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + + var req createWhitelistRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + rule := WhitelistRule{UserID: userID, ContactID: req.ContactID, TopicKeyword: req.TopicKeyword} + if err := db.Create(&rule).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": rule.ID, + "user_id": rule.UserID, + "contact_id": rule.ContactID, + "topic_keyword": rule.TopicKeyword, + }) + }) + + r.GET("/users/:id/whitelist-rules", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + + var rules []WhitelistRule + db.Where("user_id = ?", userID).Order("id").Find(&rules) + + out := make([]gin.H, 0, len(rules)) + for _, rule := range rules { + out = append(out, gin.H{ + "id": rule.ID, + "contact_id": rule.ContactID, + "topic_keyword": rule.TopicKeyword, + }) + } + c.JSON(http.StatusOK, gin.H{"whitelist_rules": out}) + }) + + r.DELETE("/users/:id/whitelist-rules/:ruleId", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + ruleID, ok := parseUintParam(c, "ruleId") + if !ok { + return + } + + var rule WhitelistRule + if err := db.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "whitelist rule not found"}) + return + } + db.Delete(&rule) + + c.JSON(http.StatusOK, gin.H{"deleted": true}) + }) + r.DELETE("/users/:id", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 3e56a5e..5661934 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -639,6 +639,155 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { } } +func TestWhitelistRuleCRUD(t *testing.T) { + server, _ := setupTestServer(t) + userID := mustSignup(t, server.URL, "화이트") + base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules" + + createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"}) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode) + } + var created map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&created) + if created["topic_keyword"] != "저녁" || created["contact_id"] != nil { + t.Fatalf("unexpected created rule: %v", created) + } + ruleID := uint(created["id"].(float64)) + + listResp, err := http.Get(base) + if err != nil { + t.Fatalf("list rules: %v", err) + } + var list map[string]interface{} + json.NewDecoder(listResp.Body).Decode(&list) + rules := list["whitelist_rules"].([]interface{}) + if len(rules) != 1 { + t.Fatalf("expected 1 rule listed, got %d", len(rules)) + } + + delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil) + delResp, err := http.DefaultClient.Do(delReq) + if err != nil { + t.Fatalf("delete rule: %v", err) + } + if delResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode) + } + + listResp2, _ := http.Get(base) + var list2 map[string]interface{} + json.NewDecoder(listResp2.Body).Decode(&list2) + if len(list2["whitelist_rules"].([]interface{})) != 0 { + t.Fatalf("expected 0 rules after delete, got %v", list2) + } + + delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil) + delAgainResp, _ := http.DefaultClient.Do(delAgain) + if delAgainResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode) + } +} + +func TestWhitelistRuleCRUDMissingUser(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestRetractTwinMessage(t *testing.T) { + server, db := setupTestServer(t) + senderID := mustSignup(t, server.URL, "되돌리기") + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" + + strconv.FormatUint(uint64(conv.ID), 10) + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial ws: %v", err) + } + defer ws.Close() + + sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin}) + if sendResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 auto-sending, got %d", sendResp.StatusCode) + } + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + var sendBroadcast map[string]interface{} + if err := ws.ReadJSON(&sendBroadcast); err != nil { + t.Fatalf("read send broadcast: %v", err) + } + if sendBroadcast["type"] != "message" { + t.Fatalf("expected type:message on send broadcast, got %v", sendBroadcast) + } + + retractResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if retractResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 retracting, got %d", retractResp.StatusCode) + } + + var retractBroadcast map[string]interface{} + if err := ws.ReadJSON(&retractBroadcast); err != nil { + t.Fatalf("read retract broadcast: %v", err) + } + if retractBroadcast["type"] != "retraction" || uint(retractBroadcast["id"].(float64)) != msgID { + t.Fatalf("unexpected retraction broadcast: %v", retractBroadcast) + } + + var message Message + db.First(&message, msgID) + if !message.Retracted { + t.Fatalf("expected message.Retracted true after retract") + } + + // Already retracted -- must not succeed again. + again := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if again.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 retracting an already-retracted message, got %d", again.StatusCode) + } +} + +func TestRetractRejectsHumanMessage(t *testing.T) { + server, db := setupTestServer(t) + senderID := mustSignup(t, server.URL, "사람") + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "안녕", SenderMode: SenderHuman, + }) + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 retracting a human message, got %d", resp.StatusCode) + } +} + +func TestRetractMissingMessage(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/messages/9999/retract", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/core-backend/models.go b/core-backend/models.go index 3a70df8..e5c850b 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -69,7 +69,12 @@ type Message struct { SenderID uint `gorm:"not null"` SenderMode SenderMode `gorm:"not null;default:human"` Text string `gorm:"not null"` - CreatedAt time.Time + // Retracted is the one-tap undo for an L2 auto-send (PRD.md §3.1, + // AGENTS.md "every automatic action needs post-hoc notification + + // one-tap undo") -- set via POST /messages/:id/retract, twin-authored + // messages only. + Retracted bool `gorm:"not null;default:false"` + CreatedAt time.Time } type TwinSettings struct { diff --git a/docs/roadmap.md b/docs/roadmap.md index 607f042..45ce129 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -59,7 +59,13 @@ `ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서 (Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고) - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 -- [ ] 사후 알림 + 되돌리기 로그 스키마/API +- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그). + 되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 + + `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, + 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. + 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. + **UI는 아직** — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 건 Flutter 쪽. + 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) **2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수** - [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 @@ -140,8 +146,8 @@ 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, `POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로 - 완료(아래 5번과 함께 구현). 남은 건 온디바이스 말투 이력 저장, 사후 알림/되돌리기 로그 — 클라이언트 - 의존이라 2.3(Flutter) 대기 + 완료(아래 5번과 함께 구현). 되돌리기 API도 완료(아래 5번 참고). 남은 건 온디바이스 말투 이력 + 저장 — 클라이언트 의존이라 2.3(Flutter) 대기 4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: @@ -151,6 +157,9 @@ - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 + - 5-2. [x] 화이트리스트 규칙 CRUD API (`POST`/`GET /users/:id/whitelist-rules`, + `DELETE /users/:id/whitelist-rules/:ruleId`) — Flutter의 자율성 설정 화면(2.3)이 붙여 쓸 수 + 있게 미리 준비. `contact_id`는 저장되지만 매칭 로직은 여전히 전역 키워드만 봄(위 2.2 참고) 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 From e1714ec5d5923da6b2c16062adb1616d1670c4b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:16:41 +0000 Subject: [PATCH 33/33] Add Flutter mobile client skeleton and defer human PoC to last Scaffold `mobile/` against core-backend APIs (signup, chat, draft, veto, retract, autonomy/whitelist). Update roadmap/README/AGENTS so participant PoC #1/#3 stays the final Phase 1 step after app build. Co-authored-by: okuma --- AGENTS.md | 7 +- README.md | 55 ++- docs/PLANNING.md | 2 +- docs/roadmap.md | 70 ++- mobile/.gitignore | 45 ++ mobile/.metadata | 30 ++ mobile/README.md | 35 ++ mobile/analysis_options.yaml | 28 ++ mobile/android/.gitignore | 14 + mobile/android/app/build.gradle.kts | 44 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 47 ++ .../com/bunsin/bunsin_mobile/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + mobile/android/build.gradle.kts | 21 + mobile/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + mobile/android/settings.gradle.kts | 25 ++ mobile/lib/config.dart | 20 + mobile/lib/main.dart | 42 ++ mobile/lib/models/models.dart | 98 +++++ .../lib/screens/autonomy_settings_screen.dart | 133 ++++++ mobile/lib/screens/chat_screen.dart | 252 +++++++++++ .../lib/screens/conversation_list_screen.dart | 91 ++++ mobile/lib/screens/signup_screen.dart | 80 ++++ mobile/lib/services/api_client.dart | 125 ++++++ mobile/lib/services/ws_client.dart | 53 +++ mobile/lib/state/session_state.dart | 65 +++ mobile/lib/widgets/message_bubble.dart | 123 ++++++ mobile/pubspec.lock | 410 ++++++++++++++++++ mobile/pubspec.yaml | 93 ++++ mobile/test/widget_test.dart | 12 + 41 files changed, 2044 insertions(+), 63 deletions(-) create mode 100644 mobile/.gitignore create mode 100644 mobile/.metadata create mode 100644 mobile/README.md create mode 100644 mobile/analysis_options.yaml create mode 100644 mobile/android/.gitignore create mode 100644 mobile/android/app/build.gradle.kts create mode 100644 mobile/android/app/src/debug/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/AndroidManifest.xml create mode 100644 mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt create mode 100644 mobile/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 mobile/android/app/src/main/res/drawable/launch_background.xml create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/values-night/styles.xml create mode 100644 mobile/android/app/src/main/res/values/styles.xml create mode 100644 mobile/android/app/src/profile/AndroidManifest.xml create mode 100644 mobile/android/build.gradle.kts create mode 100644 mobile/android/gradle.properties create mode 100644 mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 mobile/android/settings.gradle.kts create mode 100644 mobile/lib/config.dart create mode 100644 mobile/lib/main.dart create mode 100644 mobile/lib/models/models.dart create mode 100644 mobile/lib/screens/autonomy_settings_screen.dart create mode 100644 mobile/lib/screens/chat_screen.dart create mode 100644 mobile/lib/screens/conversation_list_screen.dart create mode 100644 mobile/lib/screens/signup_screen.dart create mode 100644 mobile/lib/services/api_client.dart create mode 100644 mobile/lib/services/ws_client.dart create mode 100644 mobile/lib/state/session_state.dart create mode 100644 mobile/lib/widgets/message_bubble.dart create mode 100644 mobile/pubspec.lock create mode 100644 mobile/pubspec.yaml create mode 100644 mobile/test/widget_test.dart diff --git a/AGENTS.md b/AGENTS.md index 8445c4a..f10ddc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,9 @@ These apply at every autonomy level and must not be weakened for convenience: Follow `docs/PLANNING.md`: decide → narrow → validate → specify. PoC execution (real participant recruiting for PoC #1/#3, Q3 interviews) is -currently on hold — that data collection is deferred, not cancelled. In the -meantime, work has moved into Phase 1 app-build groundwork that doesn't -depend on PoC results (see `docs/roadmap.md` Phase 1 §2 for which workstreams -those are). +deferred to the **very last** Phase 1 step — after Flutter client + remaining +server infra are done. Do not start human PoC early and do not invent defaults +for Phase 1 §3. See `docs/roadmap.md` Phase 1 §3/§4. The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8 (Flutter/Dart client, Go core backend + Python AI service, PostgreSQL, diff --git a/README.md b/README.md index a8b6837..4acd409 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,46 @@ -# hikikomori +# hikikomori / 분신 (가칭) -카카오톡 대안 메신저 아이디어 — "나를 대신해 남과 대화하는 AI 분신"이 있는 메신저 프로젝트. +카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신". + +## 구조 + +| 경로 | 역할 | +|---|---| +| `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 | +| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) | +| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) | +| `backend/` | 초기 Python 프로토타입 (참고용) | +| `poc/tone-corpus/` | PoC 실험 스크립트 | +| `mobile/` | Flutter 클라이언트 (Android 우선) | ## 문서 -- 원본 회의 자료: [`docs/idea-meeting-2026-06-29.html`](./docs/idea-meeting-2026-06-29.html) -- 기획 진행 방법: [`docs/PLANNING.md`](./docs/PLANNING.md) -- 결정 로그 (Q1~Q7): [`docs/decision-log.md`](./docs/decision-log.md) -- Vision Doc: [`docs/vision.md`](./docs/vision.md) -- PRD (v1 클로즈드 베타): [`docs/PRD.md`](./docs/PRD.md) -- 기술 설계서: [`docs/tech-design.md`](./docs/tech-design.md) -- 리스크 로그: [`docs/risk-log.md`](./docs/risk-log.md) -- 로드맵: [`docs/roadmap.md`](./docs/roadmap.md) -- PoC #1·#3 실행 계획: [`docs/poc-plan.md`](./docs/poc-plan.md) / 실행 준비물: [`docs/poc-materials.md`](./docs/poc-materials.md) -- Q3 자율성 수용성 인터뷰 가이드: [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) -- 회의 리뷰 1페이지 요약: [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) +- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) +- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) +- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) +- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) ## AI 에이전트 규칙 -- Cursor / 공통: [`AGENTS.md`](./AGENTS.md) -- Claude Code 진입점: [`CLAUDE.md`](./CLAUDE.md) +- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) ## 현재 단계 -아이디어 발산 → 컨셉 심화 → 기획 문서 세트 작성 → PoC 실행 계획·준비물·클릭 프로토타입 완료. -`docs/decision-log.md`의 결정은 아직 **잠정안**이므로, 실제 회의에서 확정/수정이 필요합니다. -다음 단계는 PoC #1·#3과 Q3 인터뷰를 실제로 진행하는 것입니다 — 계획과 자극재는 이미 준비돼 -있으니 참가자 모집부터 시작하면 됩니다 (`docs/meeting-review-summary.md` 참고). +- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 +- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 +- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) + +## 로컬 실행 (요약) + +```bash +# 터미널 1 — AI 서비스 +cd ai-service && pip install -r requirements.txt +export GEMINI_API_KEY=... # or repo-root .env +uvicorn app.main:app --port 8001 + +# 터미널 2 — 코어 백엔드 +cd core-backend && go run . + +# 터미널 3 — Flutter (Android) +cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 +``` diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 66f15bf..cf86a00 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -138,7 +138,7 @@ - [x] 말투 검색기(retrieval) 구현 — `poc/tone-corpus/retrieve_style.py` (키워드 자카드 유사도 + 최근성 가중치, 임베딩 없이 v1 설계 그대로). `generate_draft.py --history`로 연결해 스타일 예시를 손으로 안 골라도 자동 검색되게 함 -- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 +- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 — **맨 마지막 작업으로 미룸** (`roadmap.md` §3) - [x] `blind_eval.py`, `retrieve_style.py`, `generate_draft.py --history` 연동 실제 실행 검증 — 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) diff --git a/docs/roadmap.md b/docs/roadmap.md index 45ce129..f611381 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,15 +64,20 @@ `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. - **UI는 아직** — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 건 Flutter 쪽. + **되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직. 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) -**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수** -- [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 -- [ ] 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 -- [ ] 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 -- [ ] 자율성 설정 화면(L0~L2, 화이트리스트, 상대별 예외) — 무관, 착수 가능 -- [ ] 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 +**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/` +- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접 + 입장. WebSocket 수신·인간 메시지 전송 연결됨 +- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은 + **사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만) +- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 + 서버/AI 프롬프트 측과 이어서 다듬을 것 +- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은 + 서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만 +- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼. + 사후알림 전용 함/푸시는 아직 없음 **2.4 안전장치 통합** (전 구간 필수, 타협 불가) - [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 @@ -92,8 +97,7 @@ 무조건 차단되는 것까지 테스트로 확인함 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 - 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK - 없음)에서는 진행 불가 — 2.3과 함께 로컬 환경 대기 + 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업 **2.5 QA/테스트** - [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 @@ -104,8 +108,7 @@ 구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인 필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스 전부 실제 테스트로 확인함 -- [ ] 온보딩·채팅·설정 수동 QA — Flutter 클라이언트가 없어 이 환경에서는 불가, 2.3과 함께 로컬 - 환경 대기 +- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속) **2.6 베타 배포 준비** - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 @@ -123,12 +126,13 @@ 지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지 않고 명시적으로 비워둠) -#### 3. PoC 결과가 있어야 정할 수 있는 것 (맨 마지막 — 그 전엔 손대지 않는다) +#### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막** -**§2의 워크스트림(2.1~2.6)이 전부 끝나기 전까지는 이 섹션에 손대지 않는다.** 순서를 앞당겨서 -미리 추측값을 넣지 않는다 — PoC 데이터 없이 정하면 나중에 다시 뒤집어야 하고, 그게 더 큰 낭비다. -§2가 다 끝난 뒤에만 이 섹션으로 돌아온다. +**사람 대상 PoC #1/#3·Q3 인터뷰는 지금 실행할 수 없으므로 맨 마지막 작업으로 미룬다.** +§2 워크스트림(서버+Flutter)과 푸시/세션 등 남은 인프라가 끝난 뒤에만 이 섹션으로 돌아온다. +PoC 데이터 없이 기본값을 추측해 채우지 않는다. +- [ ] 사람 PoC #1/#3 실제 실행 + Q3 인터뷰 (참가자 모집 포함) — **맨 마지막** - [ ] 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 - [ ] 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 - [ ] 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요 @@ -136,34 +140,22 @@ #### 4. 권장 착수 순서 (진행 상황) -순서대로 하나씩 완료하고 다음으로 넘어간다. **6번(§3)은 1~5번이 전부 끝난 뒤에만 시작한다** — -중간에 끼워 넣지 않는다. +순서대로 하나씩 완료하고 다음으로 넘어간다. **사람 PoC(§3)는 1~5번이 전부 끝난 뒤 맨 마지막.** 1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — 설계 참고용으로 남기고 Go로 포팅 필요) -2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). - 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 - 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 -3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, - `POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로 - 완료(아래 5번과 함께 구현). 되돌리기 API도 완료(아래 5번 참고). 남은 건 온디바이스 말투 이력 - 저장 — 클라이언트 의존이라 2.3(Flutter) 대기 -4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. - 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 -5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: - `ai-service` pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 - 로직이 필요해서 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽 - (온디바이스 암호화·데이터 흐름 대시보드·수동 QA) — Flutter 대기 - - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST - /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 - 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 - - 5-2. [x] 화이트리스트 규칙 CRUD API (`POST`/`GET /users/:id/whitelist-rules`, - `DELETE /users/:id/whitelist-rules/:ruleId`) — Flutter의 자율성 설정 화면(2.3)이 붙여 쓸 수 - 있게 미리 준비. `contact_id`는 저장되지만 매칭 로직은 여전히 전역 키워드만 봄(위 2.2 참고) -6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 +2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음) +3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· + 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 +4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성 + 설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA +5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. + 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께 + - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 + §3(사람 PoC) 이후** + - 5-2. [x] 화이트리스트 규칙 CRUD API +6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) -1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 -§3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. ## Phase 2 — L3 확장 + 베타 확대 diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 0000000..48c07ef --- /dev/null +++ b/mobile/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: android + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..b575924 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,35 @@ +# 분신 mobile (Flutter) + +Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다 +(`docs/tech-design.md` §8). + +## 지금 있는 것 + +- 초대 코드 가입 (`POST /auth/signup`) +- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음) +- 채팅 전송 + WebSocket 수신 +- 분신 초안 요청 / L1 승인 발송 +- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기 +- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI + +## 실행 + +```bash +# core-backend + ai-service 가 떠 있어야 실제 호출이 된다 +cd mobile +flutter pub get + +# Android 에뮬레이터 → 호스트의 core-backend(기본 8080) +flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 + +# 실기기/로컬 네트워크 +flutter run --dart-define=CORE_API_BASE=http://:8080 +``` + +## 아직 없는 것 + +- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요) +- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) +- drift + SQLCipher 온디바이스 저장 +- 푸시 알림 +- iOS 빌드 (v1 범위 밖) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..9ed1068 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.bunsin.bunsin_mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.bunsin.bunsin_mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cf3320b --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt new file mode 100644 index 0000000..2470c58 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.bunsin.bunsin_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart new file mode 100644 index 0000000..45c2aa5 --- /dev/null +++ b/mobile/lib/config.dart @@ -0,0 +1,20 @@ +/// Runtime config for talking to `core-backend/`. +/// +/// Override at run time with: +/// `flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080` +class AppConfig { + static const coreApiBase = String.fromEnvironment( + 'CORE_API_BASE', + defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost + ); + + static String wsBase() { + final uri = Uri.parse(coreApiBase); + final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; + return Uri( + scheme: scheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ).toString(); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..1e3c25e --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'screens/conversation_list_screen.dart'; +import 'screens/signup_screen.dart'; +import 'state/session_state.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final session = SessionState(); + await session.restore(); + runApp(BunsinApp(session: session)); +} + +class BunsinApp extends StatelessWidget { + const BunsinApp({super.key, required this.session}); + + final SessionState session; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: session, + child: MaterialApp( + title: '분신', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1F6F5B), + brightness: Brightness.light, + ), + useMaterial3: true, + ), + home: Consumer( + builder: (context, s, _) { + if (s.user == null) return const SignupScreen(); + return const ConversationListScreen(); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart new file mode 100644 index 0000000..d639edc --- /dev/null +++ b/mobile/lib/models/models.dart @@ -0,0 +1,98 @@ +enum SenderMode { human, twin } + +// Product autonomy ladder names (PRD) — keep L0/L1/L2 as identifiers. +// ignore: constant_identifier_names +enum AutonomyLevel { L0, L1, L2 } + +class User { + User({required this.id, required this.displayName, required this.inviteCode}); + + final int id; + final String displayName; + final String inviteCode; + + factory User.fromJson(Map json) => User( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? '', + inviteCode: json['invite_code'] as String? ?? json['inviteCode'] as String? ?? '', + ); +} + +class TwinSettings { + TwinSettings({required this.autonomyLevel}); + + final AutonomyLevel autonomyLevel; + + factory TwinSettings.fromJson(Map json) { + final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase(); + return TwinSettings( + autonomyLevel: AutonomyLevel.values.firstWhere( + (e) => e.name == raw, + orElse: () => AutonomyLevel.L0, + ), + ); + } +} + +class ChatMessage { + ChatMessage({ + required this.id, + required this.conversationId, + required this.senderId, + required this.senderMode, + required this.text, + required this.retracted, + required this.createdAt, + }); + + final int id; + final int conversationId; + final int senderId; + final SenderMode senderMode; + final String text; + final bool retracted; + final DateTime createdAt; + + bool get isTwin => senderMode == SenderMode.twin; + + factory ChatMessage.fromJson(Map json) { + final mode = (json['sender_mode'] as String? ?? 'human').toLowerCase(); + return ChatMessage( + id: json['id'] as int, + conversationId: json['conversation_id'] as int? ?? json['conversationId'] as int? ?? 0, + senderId: json['sender_id'] as int? ?? json['senderId'] as int? ?? 0, + senderMode: mode == 'twin' ? SenderMode.twin : SenderMode.human, + text: json['text'] as String? ?? '', + retracted: json['retracted'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + ); + } +} + +class WhitelistRule { + WhitelistRule({required this.id, required this.topicKeyword, this.contactId}); + + final int id; + final String topicKeyword; + final int? contactId; + + factory WhitelistRule.fromJson(Map json) => WhitelistRule( + id: json['id'] as int, + topicKeyword: json['topic_keyword'] as String? ?? '', + contactId: json['contact_id'] as int?, + ); +} + +class DraftResult { + DraftResult({required this.status, required this.text}); + + final String status; // ok | escalate | no_key + final String text; + + factory DraftResult.fromJson(Map json) => DraftResult( + status: json['status'] as String? ?? 'ok', + text: json['text'] as String? ?? '', + ); + + bool get isEscalate => status == 'escalate'; +} diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart new file mode 100644 index 0000000..b718537 --- /dev/null +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; + +class AutonomySettingsScreen extends StatefulWidget { + const AutonomySettingsScreen({super.key}); + + @override + State createState() => _AutonomySettingsScreenState(); +} + +class _AutonomySettingsScreenState extends State { + final _keyword = TextEditingController(); + List _rules = []; + String? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final rules = await session.api.listWhitelist(session.user!.id); + setState(() => _rules = rules); + } on ApiException catch (e) { + setState(() => _error = '목록 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + @override + void dispose() { + _keyword.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('자율성 설정')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'), + ButtonSegment(value: AutonomyLevel.L1, label: Text('L1'), tooltip: '승인 후 발송'), + ButtonSegment(value: AutonomyLevel.L2, label: Text('L2'), tooltip: '화이트리스트 자동'), + ], + selected: {session.autonomyLevel}, + onSelectionChanged: (s) => session.setAutonomy(s.first), + ), + const SizedBox(height: 8), + Text( + '기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.', + style: Theme.of(context).textTheme.bodySmall, + ), + const Divider(height: 32), + Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _keyword, + decoration: const InputDecoration( + labelText: '주제 키워드', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + final text = _keyword.text.trim(); + if (text.isEmpty || session.user == null) return; + try { + final rule = await session.api.addWhitelist(session.user!.id, text); + setState(() { + _rules = [..._rules, rule]; + _keyword.clear(); + }); + } on ApiException catch (e) { + setState(() => _error = '추가 실패 (${e.statusCode})'); + } + }, + child: const Text('추가'), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 12), + if (_loading) + const Center(child: CircularProgressIndicator()) + else + ..._rules.map( + (r) => ListTile( + title: Text(r.topicKeyword), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () async { + if (session.user == null) return; + await session.api.deleteWhitelist(session.user!.id, r.id); + setState(() => _rules = _rules.where((x) => x.id != r.id).toList()); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart new file mode 100644 index 0000000..0c6c537 --- /dev/null +++ b/mobile/lib/screens/chat_screen.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../services/ws_client.dart'; +import '../state/session_state.dart'; +import '../widgets/message_bubble.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key, required this.conversationId}); + + final int conversationId; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final _input = TextEditingController(); + final _messages = []; + ConversationSocket? _socket; + StreamSubscription? _sub; + String? _banner; + DraftResult? _pendingDraft; + bool _busy = false; + + @override + void initState() { + super.initState(); + _socket = ConversationSocket(widget.conversationId)..connect(); + _sub = _socket!.events.listen(_onEvent); + } + + void _onEvent(Map event) { + final retractionId = _socket!.parseRetractionId(event); + if (retractionId != null) { + setState(() { + final i = _messages.indexWhere((m) => m.id == retractionId); + if (i >= 0) { + final m = _messages[i]; + _messages[i] = ChatMessage( + id: m.id, + conversationId: m.conversationId, + senderId: m.senderId, + senderMode: m.senderMode, + text: m.text, + retracted: true, + createdAt: m.createdAt, + ); + } + }); + return; + } + final msg = _socket!.parseMessageEvent(event); + if (msg == null) return; + if (_messages.any((m) => m.id == msg.id)) return; + setState(() => _messages.add(msg)); + } + + @override + void dispose() { + _sub?.cancel(); + _socket?.dispose(); + _input.dispose(); + super.dispose(); + } + + Future _sendHuman() async { + final session = context.read(); + final text = _input.text.trim(); + if (text.isEmpty || session.user == null) return; + setState(() => _busy = true); + try { + final msg = await session.api.sendMessage( + conversationId: widget.conversationId, + senderId: session.user!.id, + text: text, + ); + _input.clear(); + if (!_messages.any((m) => m.id == msg.id)) { + setState(() => _messages.add(msg)); + } + } on ApiException catch (e) { + setState(() => _banner = '전송 실패 (${e.statusCode})'); + } finally { + setState(() => _busy = false); + } + } + + Future _requestDraft() async { + final session = context.read(); + setState(() { + _busy = true; + _banner = null; + _pendingDraft = null; + }); + try { + final contextLines = _messages + .where((m) => !m.retracted) + .map((m) => '${m.isTwin ? "분신" : "상대"}: ${m.text}') + .toList(); + if (_input.text.trim().isNotEmpty) { + contextLines.add('상대: ${_input.text.trim()}'); + } + final draft = await session.api.requestDraft( + conversationId: widget.conversationId, + contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, + styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], + ); + setState(() { + _pendingDraft = draft; + if (draft.isEscalate) { + _banner = '에스컬레이션: ${draft.text}'; + } + }); + } on ApiException catch (e) { + setState(() => _banner = '초안 실패 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _busy = false); + } + } + + Future _sendTwinApproved() async { + final session = context.read(); + final draft = _pendingDraft; + if (draft == null || draft.isEscalate || session.user == null) return; + setState(() => _busy = true); + try { + final msg = await session.api.sendMessage( + conversationId: widget.conversationId, + senderId: session.user!.id, + text: draft.text, + senderMode: SenderMode.twin, + approved: true, + ); + setState(() { + _pendingDraft = null; + if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); + }); + } on ApiException catch (e) { + setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _busy = false); + } + } + + Future _veto() async { + final session = context.read(); + try { + await session.api.vetoConversation(widget.conversationId); + setState(() => _banner = '거부권 적용: 이 대화방에서 분신 자동응대가 중단됩니다.'); + } on ApiException catch (e) { + setState(() => _banner = '거부권 실패 (${e.statusCode})'); + } + } + + Future _retract(ChatMessage message) async { + final session = context.read(); + try { + await session.api.retractMessage(message.id); + } on ApiException catch (e) { + setState(() => _banner = '되돌리기 실패 (${e.statusCode})'); + } + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final me = session.user?.id; + + return Scaffold( + appBar: AppBar( + title: Text('대화방 #${widget.conversationId}'), + actions: [ + TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), + ], + ), + body: Column( + children: [ + if (_banner != null) + MaterialBanner( + content: Text(_banner!), + actions: [ + TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), + ], + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return MessageBubble( + message: m, + isMine: me != null && m.senderId == me, + onRetract: m.isTwin ? () => _retract(m) : null, + ); + }, + ), + ), + if (_pendingDraft != null && !_pendingDraft!.isEscalate) + Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: ListTile( + title: const Text('분신 초안 (L1 승인)'), + subtitle: Text(_pendingDraft!.text), + trailing: FilledButton( + onPressed: _busy ? null : _sendTwinApproved, + child: const Text('보내기'), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _input, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration( + hintText: '메시지', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: '초안 요청', + onPressed: _busy ? null : _requestDraft, + icon: const Icon(Icons.auto_awesome), + ), + const SizedBox(width: 4), + IconButton.filled( + tooltip: '보내기', + onPressed: _busy ? null : _sendHuman, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart new file mode 100644 index 0000000..9b31bb1 --- /dev/null +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; +import 'autonomy_settings_screen.dart'; +import 'chat_screen.dart'; + +/// v1 scaffold: conversation list is local until list API exists on core-backend. +/// Enter a conversation id to open a room (matches current Go API shape). +class ConversationListScreen extends StatefulWidget { + const ConversationListScreen({super.key}); + + @override + State createState() => _ConversationListScreenState(); +} + +class _ConversationListScreenState extends State { + final _convId = TextEditingController(text: '1'); + final _rooms = {1}; + + @override + void dispose() { + _convId.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final rooms = _rooms.toList()..sort(); + + return Scaffold( + appBar: AppBar( + title: Text('분신 · ${session.user?.displayName ?? ''}'), + actions: [ + IconButton( + tooltip: '자율성 설정', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()), + ); + }, + icon: const Icon(Icons.tune), + ), + ], + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _convId, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '대화방 ID', + border: OutlineInputBorder(), + helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입', + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final id = int.tryParse(_convId.text.trim()); + if (id == null) return; + setState(() => _rooms.add(id)); + }, + child: const Text('추가'), + ), + ], + ), + ), + for (final id in rooms) + ListTile( + leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)), + title: Text('대화방 #$id'), + subtitle: const Text('탭해서 입장'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart new file mode 100644 index 0000000..89652a9 --- /dev/null +++ b/mobile/lib/screens/signup_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; + +class SignupScreen extends StatefulWidget { + const SignupScreen({super.key}); + + @override + State createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + final _invite = TextEditingController(); + final _name = TextEditingController(); + + @override + void dispose() { + _invite.dispose(); + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Text( + '초대 코드로 클로즈드 베타에 참여합니다.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + TextField( + controller: _invite, + decoration: const InputDecoration( + labelText: '초대 코드', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: const InputDecoration( + labelText: '표시 이름', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + ), + if (session.error != null) ...[ + const SizedBox(height: 12), + Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const Spacer(), + FilledButton( + onPressed: session.loading + ? null + : () => session.signup(_invite.text.trim(), _name.text.trim()), + child: session.loading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('시작하기'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart new file mode 100644 index 0000000..4df6293 --- /dev/null +++ b/mobile/lib/services/api_client.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../config.dart'; +import '../models/models.dart'; + +class ApiException implements Exception { + ApiException(this.statusCode, this.body); + final int statusCode; + final String body; + + @override + String toString() => 'ApiException($statusCode): $body'; +} + +/// Thin REST client for `core-backend/` endpoints used by the v1 scaffold. +class ApiClient { + ApiClient({http.Client? httpClient, String? baseUrl}) + : _http = httpClient ?? http.Client(), + _base = baseUrl ?? AppConfig.coreApiBase; + + final http.Client _http; + final String _base; + + Uri _u(String path) => Uri.parse('$_base$path'); + + Future> _json( + String method, + String path, { + Map? body, + }) async { + final req = http.Request(method, _u(path)); + req.headers['Content-Type'] = 'application/json'; + if (body != null) req.body = jsonEncode(body); + final streamed = await _http.send(req); + final res = await http.Response.fromStream(streamed); + if (res.statusCode >= 400) { + throw ApiException(res.statusCode, res.body); + } + if (res.body.isEmpty) return {}; + return jsonDecode(res.body) as Map; + } + + Future> _jsonList(String path) async { + final res = await _http.get(_u(path)); + if (res.statusCode >= 400) { + throw ApiException(res.statusCode, res.body); + } + return jsonDecode(res.body) as List; + } + + Future signup({required String inviteCode, required String displayName}) async { + final json = await _json('POST', '/auth/signup', body: { + 'invite_code': inviteCode, + 'display_name': displayName, + }); + // core-backend returns {id, display_name} only — keep the invite we sent. + return User( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? displayName, + inviteCode: inviteCode, + ); + } + + Future sendMessage({ + required int conversationId, + required int senderId, + required String text, + SenderMode senderMode = SenderMode.human, + bool approved = false, + }) async { + final json = await _json('POST', '/conversations/$conversationId/messages', body: { + 'sender_id': senderId, + 'text': text, + 'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human', + if (approved) 'approved': true, + }); + return ChatMessage.fromJson(json); + } + + Future requestDraft({ + required int conversationId, + required List contextLines, + List? styleExamples, + }) async { + final body = { + 'context_lines': contextLines, + if (styleExamples != null) 'style_examples': styleExamples, + }; + final json = await _json('POST', '/conversations/$conversationId/draft', body: body); + return DraftResult.fromJson(json); + } + + Future vetoConversation(int conversationId) async { + await _json('POST', '/conversations/$conversationId/veto'); + } + + Future retractMessage(int messageId) async { + await _json('POST', '/messages/$messageId/retract'); + } + + Future patchTwinSettings(int userId, AutonomyLevel level) async { + final json = await _json('PATCH', '/users/$userId/twin-settings', body: { + 'autonomy_level': level.name, + }); + return TwinSettings.fromJson(json); + } + + Future> listWhitelist(int userId) async { + final list = await _jsonList('/users/$userId/whitelist-rules'); + return list.map((e) => WhitelistRule.fromJson(e as Map)).toList(); + } + + Future addWhitelist(int userId, String topicKeyword) async { + final json = await _json('POST', '/users/$userId/whitelist-rules', body: { + 'topic_keyword': topicKeyword, + }); + return WhitelistRule.fromJson(json); + } + + Future deleteWhitelist(int userId, int ruleId) async { + await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId'); + } +} diff --git a/mobile/lib/services/ws_client.dart b/mobile/lib/services/ws_client.dart new file mode 100644 index 0000000..6732211 --- /dev/null +++ b/mobile/lib/services/ws_client.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../config.dart'; +import '../models/models.dart'; + +/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). +class ConversationSocket { + ConversationSocket(this.conversationId); + + final int conversationId; + WebSocketChannel? _channel; + final _controller = StreamController>.broadcast(); + + Stream> get events => _controller.stream; + + void connect() { + final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); + _channel = WebSocketChannel.connect(uri); + _channel!.stream.listen( + (raw) { + if (raw is! String) return; + final decoded = jsonDecode(raw); + if (decoded is Map) { + _controller.add(decoded); + } + }, + onError: _controller.addError, + onDone: () {}, + ); + } + + ChatMessage? parseMessageEvent(Map event) { + final type = event['type'] as String?; + if (type != null && type != 'message') return null; + // Backend may wrap payload or send the message object directly. + final payload = event['message'] as Map? ?? event; + if (payload['id'] == null || payload['text'] == null) return null; + return ChatMessage.fromJson(payload); + } + + int? parseRetractionId(Map event) { + if (event['type'] != 'retraction') return null; + return event['id'] as int?; + } + + Future dispose() async { + await _channel?.sink.close(); + await _controller.close(); + } +} diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart new file mode 100644 index 0000000..343ee0d --- /dev/null +++ b/mobile/lib/state/session_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; + +class SessionState extends ChangeNotifier { + SessionState({ApiClient? api}) : _api = api ?? ApiClient(); + + final ApiClient _api; + User? user; + AutonomyLevel autonomyLevel = AutonomyLevel.L0; + String? error; + bool loading = false; + + static const _kUserId = 'user_id'; + static const _kDisplayName = 'display_name'; + static const _kInvite = 'invite_code'; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt(_kUserId); + final name = prefs.getString(_kDisplayName); + final invite = prefs.getString(_kInvite); + if (id != null && name != null && invite != null) { + user = User(id: id, displayName: name, inviteCode: invite); + notifyListeners(); + } + } + + Future signup(String inviteCode, String displayName) async { + loading = true; + error = null; + notifyListeners(); + try { + final created = await _api.signup(inviteCode: inviteCode, displayName: displayName); + user = created; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserId, created.id); + await prefs.setString(_kDisplayName, created.displayName); + await prefs.setString(_kInvite, created.inviteCode); + } on ApiException catch (e) { + error = '가입 실패 (${e.statusCode}): ${e.body}'; + } catch (e) { + error = e.toString(); + } finally { + loading = false; + notifyListeners(); + } + } + + Future setAutonomy(AutonomyLevel level) async { + if (user == null) return; + try { + final settings = await _api.patchTwinSettings(user!.id, level); + autonomyLevel = settings.autonomyLevel; + notifyListeners(); + } on ApiException catch (e) { + error = '자율성 변경 실패 (${e.statusCode})'; + notifyListeners(); + } + } + + ApiClient get api => _api; +} diff --git a/mobile/lib/widgets/message_bubble.dart b/mobile/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..4d79980 --- /dev/null +++ b/mobile/lib/widgets/message_bubble.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../models/models.dart'; + +/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지). +class MessageBubble extends StatelessWidget { + const MessageBubble({ + super.key, + required this.message, + required this.isMine, + this.onRetract, + }); + + final ChatMessage message; + final bool isMine; + final VoidCallback? onRetract; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final twin = message.isTwin; + final bg = isMine + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest; + + final bubble = Container( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(14), + border: twin + ? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (twin) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '분신', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.tertiary, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + message.retracted ? '(되돌린 메시지)' : message.text, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal, + color: message.retracted ? theme.disabledColor : null, + ), + ), + if (twin && isMine && !message.retracted && onRetract != null) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onRetract, + child: const Text('되돌리기'), + ), + ), + ], + ), + ); + + // Dashed look for twin: overlay a custom painter border when twin. + if (!twin) { + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: bubble, + ); + } + + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: CustomPaint( + painter: _DashedRRectPainter(color: theme.colorScheme.tertiary), + child: bubble, + ), + ); + } +} + +class _DashedRRectPainter extends CustomPainter { + _DashedRRectPainter({required this.color}); + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final rrect = RRect.fromRectAndRadius( + Rect.fromLTWH(1, 1, size.width - 2, size.height - 2), + const Radius.circular(14), + ); + final path = Path()..addRRect(rrect); + final dashed = _dashPath(path, dashLength: 5, gapLength: 4); + canvas.drawPath(dashed, paint); + } + + Path _dashPath(Path source, {required double dashLength, required double gapLength}) { + final metrics = source.computeMetrics(); + final out = Path(); + for (final metric in metrics) { + var distance = 0.0; + while (distance < metric.length) { + final next = distance + dashLength; + out.addPath(metric.extractPath(distance, next.clamp(0, metric.length)), Offset.zero); + distance = next + gapLength; + } + } + return out; + } + + @override + bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..e33e219 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,410 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..f10ba98 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,93 @@ +name: bunsin_mobile +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + http: ^1.6.0 + web_socket_channel: ^3.0.3 + provider: ^6.1.5+1 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..10a4477 --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,12 @@ +import 'package:bunsin_mobile/main.dart'; +import 'package:bunsin_mobile/state/session_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows signup when logged out', (tester) async { + final session = SessionState(); + await tester.pumpWidget(BunsinApp(session: session)); + expect(find.text('분신'), findsOneWidget); + expect(find.text('시작하기'), findsOneWidget); + }); +}