제품명 변경(분신→와카뷰) + 소프트 그라디언트/글래스모피즘 UI + 브랜드 로딩 화면

명칭 변경:
- decision-log.md Q6 확정: "분신"(가칭) → "와카뷰 (Ykavu)" — 焚身(분신자살)
  동음이의 리스크도 있었고 메신저 브랜드로 부르기 무거웠음. Master가 최종
  선택한 이름으로 변경(2026-07-31), 파생 문서(AGENTS.md/CLAUDE.md/PRD 등)·
  Flutter 앱 텍스트·web manifest/index.html·AndroidManifest 라벨까지 전부 반영
- ai-service: 본인확인 고정 문구(identity.py)·시스템 프롬프트(generation.py)도
  갱신 — 새 이름 기준으로 "본인이야 와카뷰야?" 류 질문을 감지하도록 정규식도
  같이 손봄(단순 문자열 치환만으론 어미 형태가 안 맞아서 테스트 추가/조정)
- poc/tone-corpus/의 실험용 프롬프트는 의도적으로 그대로 둠(ai-service README에
  이미 명시된 대로 프로덕션과 분리된 실험 도구)

UI/UX:
- mobile/lib/theme/app_theme.dart: 시드 컬러를 인디고/라벤더로 변경, 화면 전체에
  깔리는 소프트 그라디언트(라이트: 라벤더→스카이→핑크 파스텔, 다크: 딥 인디고→
  네이비→플럼) + 카드/인풋/칩을 반투명 "글래스" 서피스로 전환
- mobile/lib/widgets/gradient_backdrop.dart(신규): MaterialApp.builder에 연결해
  모든 화면에 자동으로 그라디언트 배경 적용
- mobile/lib/screens/splash_screen.dart(신규) + main.dart 재구성: 기존엔
  session.restore()를 기다리는 동안 아무 것도 안 그려서 흰 화면/텍스트만 뜨는
  구간이 있었음 — runApp을 먼저 하고 restore 동안 브랜드 스플래시가 뜨도록 변경
- chat_screen·autonomy_settings_screen·data_flow_screen의 커스텀 패널들도
  글래스 스타일로 맞춤

BunsinApp -> YkavuApp (mobile/lib/main.dart, test/widget_test.dart 동기화)

Flutter SDK가 없는 환경이라 flutter analyze/run으로 직접 컴파일 검증은 못했음 —
중괄호/괄호 균형과 기존에 검증된 API 패턴 위주로 신중하게 작성함. go test·
pytest는 전부 통과.
This commit is contained in:
Claude 2026-07-31 01:15:42 +00:00
parent 6eceef44f8
commit ebf333d287
No known key found for this signature in database
41 changed files with 352 additions and 168 deletions

View File

@ -1,6 +1,6 @@
# Project Instructions — hikikomori / 분신 (가칭)
# Project Instructions — hikikomori / 와카뷰 (가칭)
This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with
This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 와카뷰") with
Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs.
Canonical product decisions live under `docs/`. Prefer linking to them over
@ -29,8 +29,8 @@ Notes:
## Product identity (v1 working assumptions)
- Name (working): **분신**
- One-liner: ChatGPT talks *with* me; 분신 talks *as* me *to others*
- 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**
@ -57,7 +57,7 @@ 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.
- 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.

View File

@ -1,4 +1,4 @@
# Claude Code — hikikomori / 분신
# Claude Code — hikikomori / 와카뷰
Follow the project instructions in [@AGENTS.md](./AGENTS.md).
@ -6,7 +6,7 @@ Quick context:
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is
**D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults.
- Working product name: **분신** (가칭 확정).
- Working product name: **와카뷰** (가칭 확정).
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.

View File

@ -1,6 +1,6 @@
# hikikomori / 분신 (가칭)
# hikikomori / 와카뷰 (가칭)
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신".
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
## 구조

View File

@ -1,4 +1,4 @@
# 분신 AI service (Python)
# 와카뷰 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 —

View File

@ -32,7 +32,7 @@ def load_dotenv_if_present():
break
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
지켜야 :
@ -40,8 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \
(서버가 고정 문구로 먼저 처리하지만, 여기까지 경우에도 분신이라고 답한다).
- 상대가 "본인이야/와카뷰야?"처럼 정체를 물으면 와카뷰임을 정직하게 밝힌다 \
(서버가 고정 문구로 먼저 처리하지만, 여기까지 경우에도 와카뷰라고 답한다).
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""

View File

@ -10,17 +10,17 @@ from dataclasses import dataclass
# Fixed product copy — tune only after PoC #3; do not invent alternate defaults.
IDENTITY_REPLY = (
"지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘."
"지금은 와카뷰가 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘."
)
# Incoming peer questions that should trigger the fixed identity reply.
_IDENTITY_QUESTION = re.compile(
r"("
r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)"
r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)"
r"|지금\s*(본인|분신)"
r"|와카뷰\s*(이야|야\??|인가요|이니|임\??|맞아|맞음)"
r"|지금\s*(본인|와카뷰)"
r"|진짜\s*(야|임|인가요)"
r"|너\s*(사람|본인|분신)"
r"|너\s*(사람|본인|와카뷰)"
r")",
re.IGNORECASE,
)

View File

@ -15,7 +15,7 @@ async def lifespan(app: FastAPI):
yield
app = FastAPI(title="분신 AI service", lifespan=lifespan)
app = FastAPI(title="와카뷰 AI service", lifespan=lifespan)
@app.get("/health")

View File

@ -4,8 +4,9 @@ from app.generation import draft_reply
def test_identity_phrases_match():
for q in [
"지금 본인이야 분신이야?",
"분신이야?",
"지금 본인이야 와카뷰야?",
"와카뷰야?",
"와카뷰이야?",
"너 본인 맞아?",
"진짜야?",
]:
@ -23,7 +24,7 @@ def test_draft_returns_fixed_identity_without_llm(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = draft_reply(
["ㅇㅇ 알겠음"],
["상대: 지금 본인이야 분신이야?"],
["상대: 지금 본인이야 와카뷰야?"],
)
assert status == "ok"
assert text == IDENTITY_REPLY

View File

@ -1,4 +1,4 @@
# 분신 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
# 와카뷰 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다
(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로,

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from . import models
from .db import Base, engine, get_db
app = FastAPI(title="분신 backend")
app = FastAPI(title="와카뷰 backend")
@app.on_event("startup")

View File

@ -1,4 +1,4 @@
# 분신 core-backend (Go)
# 와카뷰 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
@ -127,7 +127,7 @@ go test ./... -v
- `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차 근사치(대화방 단위)이지 확정 정의는 아님.
`peer_veto_rate`는 vision.md "와카뷰 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님.
생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
## 아직 없는 것 (다음 워크스트림)

View File

@ -163,7 +163,7 @@ func registerBRoutes(r *gin.Engine, db *gorm.DB) {
return
}
if req.Title == "" {
req.Title = "분신 테스트"
req.Title = "와카뷰 테스트"
}
if req.Body == "" {
req.Body = "push test"
@ -189,7 +189,7 @@ const adminDashboardHTML = `<!doctype html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>분신 · admin metrics</title>
<title>와카뷰 · admin metrics</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
body { margin: 0; padding: 24px; }
@ -204,7 +204,7 @@ const adminDashboardHTML = `<!doctype html>
</style>
</head>
<body>
<h1>분신 운영 지표</h1>
<h1>와카뷰 운영 지표</h1>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div>

View File

@ -104,7 +104,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
"escalations_by_reason": escalationsByReason,
"conversations_total": conversationsTotal,
"conversations_vetoed": conversationsVetoed,
// Approximates vision.md's "분신 거부율" metric at conversation
// 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.
@ -212,7 +212,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return
}
@ -232,7 +232,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
MessageSnippet: req.Text,
})
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
_, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{
_, _, _ = notifyUser(db, req.SenderID, "와카뷰 확인 필요", result.Reason, map[string]string{
"type": "escalation",
"conversation_id": strconv.FormatUint(uint64(convID), 10),
"reason": result.Reason,
@ -252,7 +252,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
switch level {
case AutonomyL0:
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return
case AutonomyL1:
if !req.Approved {

View File

@ -1,10 +1,10 @@
# 프로젝트 기획 접근 방법 — AI 분신 메신저
# 프로젝트 기획 접근 방법 — AI 와카뷰 메신저
원본 회의 자료: [`idea-meeting-2026-06-29.html`](./idea-meeting-2026-06-29.html)
## 0. 지금 어디까지 왔나
회의 자료는 "발산 → 컨셉 탐색 → AI 분신으로 좁혀 설계 심화"까지 진행됐다.
회의 자료는 "발산 → 컨셉 탐색 → AI 와카뷰로 좁혀 설계 심화"까지 진행됐다.
즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다.
이 단계에서 실패하는 가장 흔한 패턴은 두 가지다.
@ -34,12 +34,12 @@
| # | 질문 | 잠정 결정 | 상태 |
|---|------|------|------|
| Q1 | AI 분신으로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
| Q1 | AI 와카뷰로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 |
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 |
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 |
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 |
| Q6 | 서비스 이름 | "분신" (가칭) | 제안 (가칭) |
| Q6 | 서비스 이름 | "와카뷰" (가칭) | 제안 (가칭) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 |
## 3. MVP 시나리오 좁히기
@ -51,10 +51,10 @@
| 🌙 읽씹 종결 | L0~L2 | 높음 (전 국민 공감) | 낮음 (컨텍스트 응답 1건) | **1순위 추천** — 가장 좁고 명확 |
| 📚 단톡 따라잡기 | L0 | 중간 | 낮음 (요약만) | 발송 권한 불필요, 안전 |
| 🚫 비호감 대화 방어 | L2 | 중간 | 중간 (의도 판별 필요) | 오탐 리스크 존재 |
| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (분신 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 |
| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (와카뷰 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 |
**권장:** "읽씹 종결"과 "단톡 따라잡기"는 둘 다 L0~L2로 커버되고 같은 파이프라인(수신 메시지 요약 →
맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(분신 협상)는
맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(와카뷰 협상)는
로드맵의 다음 단계로 명시적으로 미룬다.
## 4. 기술 검증(PoC) 우선순위
@ -66,7 +66,7 @@
OS/앱 버전에 따라 얼마나 자주 깨지는지 확인 (OS 레이어 트랙을 갈 경우 필수)
3. **자동응대가 "이상하다"고 느껴지는 임계점은 어디인가** — 실제 대화 로그로 사용자 테스트, 뱃지·거부권
같은 투명성 장치가 실제로 신뢰를 회복시키는지 확인
4. **분신 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로
4. **와카뷰 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로
설계 문서 수준에서만 먼저 검증 (턴 수 제한, 타임아웃, 결렬 시 에스컬레이션 규칙)
## 5. 범위 관리 원칙 — "켜는 만큼만 만든다"
@ -75,7 +75,7 @@
- **MVP**: L0(비서 모드) + L1(제안 모드) + L2(부분 위임, 좁은 화이트리스트 주제만)
- **v2**: L3(자리비움 응대) — 사용자 신뢰가 쌓인 뒤
- **v3+**: L4(분신 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤
- **v3+**: L4(와카뷰 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤
절대 안전선(금전·약속 확정·민감 내용은 항상 사람에게 에스컬레이션)은 L0부터 L4까지 전 단계에서
예외 없이 지킨다 — 이건 범위를 줄여도 타협하지 않는 항목이다.
@ -85,9 +85,9 @@
두 트랙은 경쟁 관계가 아니라 순서 문제다.
- **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의
API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(분신의 자연스러움·신뢰)과 제3자
API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(와카뷰의 자연스러움·신뢰)과 제3자
플랫폼 제약(카카오톡 등의 자동화 정책)이 동시에 섞여 실패 원인을 구분하기 어렵다.
- **자체 메신저(클로즈드 베타)**: 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해
- **자체 메신저(클로즈드 베타)**: 와카뷰 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해
통제된 소규모 그룹으로 핵심 가설만 순수하게 검증할 수 있다. "메신저를 옮겨야 한다"는 채택
장벽은 있지만, 베타 단계에서는 어차피 소규모 초대 기반이라 큰 문제가 아니다.
@ -108,7 +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 자율성 수용성 인터뷰 (분신 사용자 관점)
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (와카뷰 사용자 관점)
10. [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의에서 Q1~Q7을 확정할 때 쓰는 1페이지 요약
## 8. 다음 액션 체크리스트

View File

@ -1,34 +1,34 @@
# PRD — 분신 v1 (클로즈드 베타)
# PRD — 와카뷰 v1 (클로즈드 베타)
범위: `vision.md`의 가치 제안을 자체 앱 클로즈드 베타로 구현한다. 자율성 L0~L2, 시나리오는
"읽씹 종결"과 "단톡 따라잡기" 두 가지로 한정한다. (근거: `decision-log.md` Q3, Q5)
## 1. 페르소나
- **주 사용자(분신을 쓰는 나)** — 메신저 응답 압박을 자주 느끼는 개인. 분신을 켜고, 응답을
- **주 사용자(와카뷰를 쓰는 나)** — 메신저 응답 압박을 자주 느끼는 개인. 와카뷰를 켜고, 응답을
검토·승인하거나 위임 범위를 조정한다.
- **상대방**분신 사용자와 대화하는 사람. 앱을 설치하지 않았어도 분신의 응답을 받을 수 있어야
하며(v1은 자체 앱 내부이므로 상대방도 사실상 앱 사용자), 분신 여부를 언제든 확인·거부할 수 있다.
- **상대방**와카뷰 사용자와 대화하는 사람. 앱을 설치하지 않았어도 와카뷰의 응답을 받을 수 있어야
하며(v1은 자체 앱 내부이므로 상대방도 사실상 앱 사용자), 와카뷰 여부를 언제든 확인·거부할 수 있다.
## 2. 유저 플로우
### 2.1 온보딩 (말투 학습)
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
3. 분신 자율성 기본값은 **L0(비서 모드)** — 사용자가 명시적으로 올려야 L1, L2로 이동
3. 와카뷰 자율성 기본값은 **L0(비서 모드)** — 사용자가 명시적으로 올려야 L1, L2로 이동
### 2.2 읽씹 종결 시나리오 (핵심)
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
2. 상대가 메시지를 보냄 → 분신이 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
즉시 자동 발송, 사후 알림
5. 상대에게는 분신 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
### 2.3 단톡 따라잡기 시나리오
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
2. 분신이 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
3. 답장이 필요한 항목에는 초안 버튼 제공 (발송은 항상 사용자 승인 — 이 시나리오는 L0 고정,
자동 발송 없음)
@ -38,14 +38,14 @@
| 기능 | 설명 |
|---|---|
| 분신 뱃지 | 분신이 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 |
| 화이트리스트 주제 | L2에서 자동 발송을 허용할 주제/상대를 사용자가 직접 등록 |
| 에스컬레이션 규칙 | 금전, 약속 확정, 감정적/민감 내용 감지 시 자동 처리 금지, 무조건 사용자에게 넘김 |
| 실시간 본인 확인 | 상대가 "지금 본인이야 분신이야?" 물으면 분신이 스스로 정직하게 답함 |
| 거부권 | 상대가 "분신 말고 본인이랑만" 요청 시 해당 대화에서 자동응대 즉시 중단 |
| 사후 알림 + 되돌리기 | 분신이 취한 모든 자동 행동은 알림 로그에 남고 원클릭 취소 가능 |
| 실시간 본인 확인 | 상대가 "지금 본인이야 와카뷰가야?" 물으면 와카뷰가 스스로 정직하게 답함 |
| 거부권 | 상대가 "와카뷰 말고 본인이랑만" 요청 시 해당 대화에서 자동응대 즉시 중단 |
| 사후 알림 + 되돌리기 | 와카뷰가 취한 모든 자동 행동은 알림 로그에 남고 원클릭 취소 가능 |
| 단톡 요약(멘션 기준) | 안 본 동안 나 언급/결정 사항만 골라 요약 |
| 관계별 페르소나(최소 2종) | 가까운 사이 / 공식적인 사이 톤 구분 |
@ -57,7 +57,7 @@
### 3.3 P2 / 명시적 범위 밖 (v2+)
- L3 자리비움 전면 응대, L4 분신 협상 — `roadmap.md` 참고
- L3 자리비움 전면 응대, L4 와카뷰 협상 — `roadmap.md` 참고
- OS 레이어(타 앱 알림 관통) — `roadmap.md` 참고
- 다중 페르소나, 구독 결제 — 수익 모델은 베타 이후
@ -65,8 +65,8 @@
| 상황 | 처리 |
|---|---|
| 분신이 잘못된 맥락으로 응답 초안을 만듦 | L1은 발송 전 사용자 검토이므로 그 자리에서 수정. L2는 사후 알림 + 되돌리기 |
| 상대가 분신에게 확정을 요구(예: "그럼 3시 확정이지?") | 분신은 확정 불가 — "본인 확인 필요"로 응답하고 사용자에게 에스컬레이션 |
| 와카뷰가 잘못된 맥락으로 응답 초안을 만듦 | L1은 발송 전 사용자 검토이므로 그 자리에서 수정. L2는 사후 알림 + 되돌리기 |
| 상대가 와카뷰에게 확정을 요구(예: "그럼 3시 확정이지?") | 와카뷰는 확정 불가 — "본인 확인 필요"로 응답하고 사용자에게 에스컬레이션 |
| 상대가 짧은 시간에 메시지 도배 | 스팸 감지 임계치 초과 시 응대 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요) |
| 사용자가 여러 상대에게 다른 자율성 레벨을 원함 | 상대별 예외 설정으로 지원 (전역 기본값 + 오버라이드) |
| 온보딩 시 임포트할 기존 대화가 없음 | 질문 기반 초기 세팅 경로로 폴백 |

View File

@ -9,12 +9,12 @@
| # | 질문 | 결정 | 근거 | 상태 |
|---|------|------|------|------|
| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
| Q1 | AI 와카뷰로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 와카뷰는 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 |
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(와카뷰 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 와카뷰 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 |
| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) |
| Q6 | 서비스 이름 | **"와카뷰 (Ykavu)"** | 기존 가칭 "분신"에서 변경(2026-07-31). "분신"(分身)은 메신저 브랜드로 부르기 무겁고, 焚身(분신자살)과 동음이의 리스크도 있었음. "와카뷰(Ykavu)"는 Master가 최종 선택한 브랜드명 — 메신저 이름으로 짧고 부르기 쉬움. 상표 등록 여부는 여전히 베타 반응 이후 재검토 | 확정 (2026-07-31 명칭 변경) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 |
확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정).
@ -27,7 +27,7 @@
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에
검증하면 실패 원인을 구분할 수 없다.
- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
- 자체 앱(초대 기반 클로즈드 베타)에서는 와카뷰 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능.
@ -36,7 +36,7 @@
## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
- 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
- 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
- 상표/네이밍 최종안 (Q6은 가칭 확정)
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
- 베타 참가자 모집 규모와 방식

View File

@ -15,7 +15,7 @@
| `risk-log.md` | 리스크 + 완화 상태 | 결정 반영 완료 |
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (와카뷰 사용자 관점) | 계획 완료, **실행 전** |
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
@ -25,12 +25,12 @@
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
- [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`)
- [x] **Q1** AI 와카뷰 컨셉 확정? → 확정: 예 (`decision-log.md`)
- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
- [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지
- [x] **Q6** 서비스 이름 "와카뷰"(가칭)? → 확정: 가칭 유지
- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
## 회의에서 정할 것 (Q1~Q7 확정 외)

View File

@ -5,7 +5,7 @@
## 1. PoC #1 참가자 모집 문구
> 안녕하세요! 요즘 만들고 있는 "분신"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나
> 안녕하세요! 요즘 만들고 있는 "와카뷰"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나
> 잘 따라 하는지 테스트해보려고 해요. 참여 방법은 이렇습니다.
>
> 1. 최근 카톡 대화 내보내기(50~100개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요
@ -40,29 +40,29 @@
```
상대: 오늘 좀 힘든 일이 있었어... 얘기해도 될까
분신: (감정적으로 무거운 주제로 판단, 보류)
와카뷰: (감정적으로 무거운 주제로 판단, 보류)
죄송해요, 이런 얘기는 제가 대신 답하기 어려워서 지우님께 바로 알려드렸어요.
조금만 기다려주시면 지우님이 직접 답장하실 거예요.
(본인에게 알림: "은채님이 힘든 일이 있다고 하셨어요 — 직접 답장이 필요해요")
```
**관찰 포인트**: 분신이 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지,
**관찰 포인트**: 와카뷰가 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지,
아니면 오히려 더 신뢰하는지 — 감정 주제에서의 반응은 업무/약속 주제와 다를 수 있음.
### 역할극 진행 스크립트 (진행자용)
1. (도입, 1분) "지금부터 짧은 대화를 몇 번 나눠볼 거예요. 상대방이 되어 편하게 대화해주시면 됩니다."
2. (프로토타입 시연, 3분) 4개 탭을 순서대로 같이 보며 진행 — 매 장면 뒤 "지금 느낌이 어땠어요?" 짧게 질문
3. (시나리오 E 역할극, 3분) 진행자가 분신 역할, 참가자가 상대방 역할로 실제 대화
3. (시나리오 E 역할극, 3분) 진행자가 와카뷰 역할, 참가자가 상대방 역할로 실제 대화
4. (사후 인터뷰, 5분) 아래 §3 질문지 사용
## 3. 사후 인터뷰 질문지 (PoC #3 공용)
1. 방금 대화가 불편하게 느껴진 순간이 있었나요? 있다면 어떤 부분이었나요?
2. "본인이야 분신이야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요?
3. 분신이 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요?
2. "본인이야 와카뷰가야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요?
3. 와카뷰가 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요?
4. 거부권("본인이랑만 얘기하고 싶다")을 쓴다면, 언제 쓰고 싶을 것 같나요?
5. 이런 분신과 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
5. 이런 와카뷰와 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
## 참가자 확보 규모 (재확인)

View File

@ -13,17 +13,17 @@
### 방법
1. **샘플 수집**: 참가자 3~5명당 최근 대화 50~100개(카카오톡 내보내기 등) 확보. 민감한 대화는
참가자가 직접 제외하도록 안내 (연구 목적 최소 수집 원칙, `tech-design.md` §5와 동일한 원칙 적용)
2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 분신
2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 와카뷰
응답과 비교하지 않고 감춰둔 답)으로 나눈다
3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 분신이 응답 초안을 생성
4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 분신 초안"을
3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 와카뷰가 응답 초안을 생성
4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 와카뷰 초안"을
섞어서 보여주고 구분 가능한지 + "내(그의) 말투 같다"를 5점 척도로 평가
### 평가 기준 (Go/No-Go)
| 지표 | 기준 |
|---|---|
| "말투 같다" 평균 점수 | 4/5 이상 |
| 실제 답장과 분신 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) |
| 실제 답장과 와카뷰 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) |
| 명백히 "이상하다"는 반응 비율 | 10% 미만 |
기준 미달 시: `vision.md`의 가치제안 자체를 재검토하거나, 온디바이스 학습 방식(특징 추출 방식,
@ -45,7 +45,7 @@
주입하는 방식 — `tech-design.md` §2-1에 구체화. PoC #1에서 수집한 참가자 샘플이 바로 이 메커니즘의
"말투 예시"가 된다.
## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX)
## PoC #3 — 사칭/신뢰 수용성 (와카뷰 뱃지·거부권 UX)
### 목적
`decision-log.md` Q4("사칭 우려 대응 충분한가?")는 아직 가설이다. 뱃지·거부권·확정 불가 원칙이
@ -56,15 +56,15 @@
프로토타입을 이미 제작해둠 (`PLANNING.md` §8 참고). 역할극에서 이 프로토타입을 그대로 보여주며
"이런 상황이면 어떨 것 같은지" 반응을 끌어내는 자극재로 사용한다. 추가로 필요하면 감정적인
대화 등 프로토타입에 없는 케이스만 별도 스크립트로 보완
2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 분신과 대화하는
경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신
2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 와카뷰와 대화하는
경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 와카뷰 역할을 대신
수행해도 됨, 이 단계에서는 UX/신뢰 반응만 측정)
3. **관찰 포인트**:
- 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가
- "본인이야 분신이야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가
- "본인이야 와카뷰가야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가
- 거부권("본인이랑만 얘기하고 싶다")을 실제로 쓰고 싶어지는 순간이 있는가, 그 요청이 잘 반영됐다고 느끼는가
- 약속/금전 얘기가 나왔을 때 분신이 보류하는 것에 대한 반응 (안심 vs 답답함)
4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 분신과 대화하고 싶은가" 개방형 질문
- 약속/금전 얘기가 나왔을 때 와카뷰가 보류하는 것에 대한 반응 (안심 vs 답답함)
4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 와카뷰와 대화하고 싶은가" 개방형 질문
### 평가 기준 (Go/No-Go)
| 지표 | 기준 |

View File

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

View File

@ -7,7 +7,7 @@
- 온디바이스 말투 학습 품질 검증 (`PLANNING.md` §4 PoC #1)
- 에스컬레이션 판정기(규칙 기반) 최소 프로토타입으로 오탐/누락 감 잡기
- 목표: "분신이 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보
- 목표: "와카뷰가 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보
## Phase 1 — v1 클로즈드 베타 (자체 앱, `PRD.md` 범위)
@ -73,7 +73,7 @@
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
@ -231,10 +231,10 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- 확산 후 완전한 기능(L3·L4 등)이 필요하면 자체 앱으로 유도 — 시작 순서를 뒤집는 뜻이 아님
- 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응
## Phase 4 — L4 분신 협상 + B2B 확장
## Phase 4 — L4 와카뷰 협상 + B2B 확장
- 사용자 기반이 어느 정도 쌓여 네트워크 효과가 의미 있을 때 착수
- 기업용 고객 응대 분신(B2B)은 이 시점 이후 별도 트랙으로 검토
- 기업용 고객 응대 와카뷰(B2B)은 이 시점 이후 별도 트랙으로 검토
## 명시적으로 지금 계획하지 않는 것

View File

@ -1,4 +1,4 @@
# 기술 설계서 — 분신 v1
# 기술 설계서 — 와카뷰 v1
`PRD.md` 범위(자체 앱, L0~L2, 읽씹 종결 + 단톡 따라잡기)를 구현하기 위한 아키텍처 개요.
상세 API 스펙이 아니라 경계와 원칙을 정의하는 문서다 — 실제 구현 시 세부 설계는 별도로 좁혀나간다.
@ -76,10 +76,10 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
## 4. 투명성 구현
- **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용
- **와카뷰 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용
(서버가 신뢰의 원천 — 클라이언트 임의 조작 방지를 위해 서명 포함)
- **실시간 본인 확인**: "본인/분신" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리,
분신은 항상 정직하게 "저는 분신입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정)
- **실시간 본인 확인**: "본인/와카뷰" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리,
와카뷰는 항상 정직하게 "저는 와카뷰입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정)
- **거부권**: 대화방 단위 플래그(`twin_disabled_by_peer`) — 상대방의 거부 요청이 감지되면 즉시
해당 대화방에서 L1/L2 자동 동작을 끄고 L0으로 강등
@ -106,7 +106,7 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
- OS 레이어(알림 파싱 기반 타 앱 관통) — v2. 지금 만들면 카카오톡 등 UI 변경에 따라 계속 깨지는
파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다.
- 분신 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음.
- 와카뷰 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음.
- 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충.
## 8. 기술 스택 결정 (Phase 1)

View File

@ -1,10 +1,10 @@
# 유저 인터뷰 가이드 — Q3 자율성 수용성 (분신 사용자 관점)
# 유저 인터뷰 가이드 — Q3 자율성 수용성 (와카뷰 사용자 관점)
`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 분신을 얼마나 신뢰하는지를 본다.
이 문서는 반대쪽 — **분신을 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다.
`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 와카뷰를 얼마나 신뢰하는지를 본다.
이 문서는 반대쪽 — **와카뷰를 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다.
`decision-log.md` Q3("자율성 몇 단계까지 출시?")를 "L0~L2만"으로 잠정 결정했는데, 이게 실제
사용자들의 위임 의향과 맞는지 확인하는 것이 목적이다. 두 인터뷰는 서로 대체하지 않는다 —
분신이 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가
와카뷰가 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가
틀린 것이다.
## 대상 및 규모

View File

@ -1,4 +1,4 @@
# Vision Doc — 분신 (가칭)
# Vision Doc — 와카뷰 (가칭)
## 문제
@ -14,28 +14,28 @@ v1 이후 확장 대상으로 명시적으로 제외한다. (근거: `decision-l
## 가치 제안 (한 문장)
> 분신은 내가 답할 수 없는 순간에도, 나 대신 나답게 응답해 읽씹 압박과 응답 피로를 없애주는
> 와카뷰는 내가 답할 수 없는 순간에도, 나 대신 나답게 응답해 읽씹 압박과 응답 피로를 없애주는
> 메신저다.
## 이번 v1에서 하지 않는 것 (Non-goals)
- 분신 간 협상(L4, 약속 자동 조율) — 네트워크 효과가 필요한 단계라 사용자 기반이 있어야 의미가 있음
- 와카뷰 간 협상(L4, 약속 자동 조율) — 네트워크 효과가 필요한 단계라 사용자 기반이 있어야 의미가 있음
- 자리비움 전면 자동응대(L3) — 신뢰가 쌓이기 전에는 리스크가 큼
- 카카오톡/인스타그램 등 타 채널 위 OS 레이어 — v2 성장 전략으로 미룸
- 기업용(B2B) 고객 응대 분신
- 기업용(B2B) 고객 응대 와카뷰
## 성공 지표 (v1 클로즈드 베타 기준)
| 지표 | 목표 | 측정 방법 |
|---|---|---|
| 분신 응답 자연스러움 | 베타 참가자의 70% 이상이 "내 말투 같다"고 평가 | 온보딩 후 설문 |
| 신뢰 유지 | 상대방의 분신 거부율 10% 미만 | 거부권 사용 로그 |
| 와카뷰 응답 자연스러움 | 베타 참가자의 70% 이상이 "내 말투 같다"고 평가 | 온보딩 후 설문 |
| 신뢰 유지 | 상대방의 와카뷰 거부율 10% 미만 | 거부권 사용 로그 |
| 핵심 시나리오 도달률 | 베타 참가자 50% 이상이 "읽씹 종결" 시나리오를 1주 내 실사용 | 이벤트 로그 |
| 리텐션 | 2주차 재사용률 40% 이상 | DAU/재방문 로그 |
| 안전선 위반 0건 | 금전/약속 확정을 분신이 자동 처리한 사례 0건 | 에스컬레이션 로그 감사 |
| 안전선 위반 0건 | 금전/약속 확정을 와카뷰가 자동 처리한 사례 0건 | 에스컬레이션 로그 감사 |
## 핵심 축 요약
- **정체성 한 줄:** 챗GPT가 나와 대화하는 AI라면, 분신은 나를 대신해 남과 대화하는 나.
- **정체성 한 줄:** 챗GPT가 나와 대화하는 AI라면, 와카뷰는 나를 대신해 남과 대화하는 나.
- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은
어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다.

View File

@ -1,4 +1,4 @@
# 분신 mobile (Flutter)
# 와카뷰 mobile (Flutter)
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
(`docs/tech-design.md` §8).
@ -10,8 +10,8 @@ Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드
- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
- 채팅 히스토리 로드 + WebSocket 수신
- 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송)
- 분신 뱃지 · 거부권 · 되돌리기
- 와카뷰 초안 / L1 승인(수정·버리기·재초안·승인 발송)
- 와카뷰 뱃지 · 거부권 · 되돌리기
- 사후 알림 함 (`GET /users/:id/escalation-logs`)
- 자율성 L0~L2 + 화이트리스트 CRUD UI
@ -47,9 +47,9 @@ HTTP로 검증한다.
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
2. 연락처에 상대 사용자 ID 등록 → 대화 시작
3. 메시지 전송 · 새로고침 후 히스토리 유지
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 와카뷰 뱃지 확인
5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
6. 분신 메시지 되돌리기 · 거부권
6. 와카뷰 메시지 되돌리기 · 거부권
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
## 로컬 DB (drift + SQLCipher)

View File

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="분신"
android:label="와카뷰"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">

View File

@ -4,18 +4,63 @@ import 'package:provider/provider.dart';
import 'screens/conversation_list_screen.dart';
import 'screens/onboarding_tone_screen.dart';
import 'screens/signup_screen.dart';
import 'screens/splash_screen.dart';
import 'state/session_state.dart';
import 'theme/app_theme.dart';
import 'widgets/gradient_backdrop.dart';
Future<void> main() async {
void main() {
WidgetsFlutterBinding.ensureInitialized();
final session = SessionState();
await session.restore();
runApp(BunsinApp(session: session));
runApp(const _Bootstrap());
}
class BunsinApp extends StatelessWidget {
const BunsinApp({super.key, required this.session});
/// Shows [SplashScreen] while [SessionState.restore] is in flight, then
/// swaps to the real app. Runs `runApp` immediately (rather than awaiting
/// restore() first) so the branded splash actually paints instead of
/// leaving a blank frame during the async gap.
class _Bootstrap extends StatefulWidget {
const _Bootstrap();
@override
State<_Bootstrap> createState() => _BootstrapState();
}
class _BootstrapState extends State<_Bootstrap> {
SessionState? _session;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final session = SessionState();
await session.restore();
if (!mounted) return;
setState(() => _session = session);
}
@override
Widget build(BuildContext context) {
final session = _session;
if (session == null) {
return MaterialApp(
title: '와카뷰',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
builder: (context, child) => GradientBackdrop(child: child),
home: const SplashScreen(),
);
}
return YkavuApp(session: session);
}
}
class YkavuApp extends StatelessWidget {
const YkavuApp({super.key, required this.session});
final SessionState session;
@ -24,11 +69,12 @@ class BunsinApp extends StatelessWidget {
return ChangeNotifierProvider.value(
value: session,
child: MaterialApp(
title: '분신',
title: '와카뷰',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
builder: (context, child) => GradientBackdrop(child: child),
home: Consumer<SessionState>(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();

View File

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
import 'data_flow_screen.dart';
import 'sessions_screen.dart';
@ -50,8 +51,8 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
}
static const _levelDescriptions = {
AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.',
AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
AutonomyLevel.L0: '와카뷰가 초안만 만들고, 발송은 항상 직접 합니다.',
AutonomyLevel.L1: '와카뷰가 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
};
@ -106,8 +107,9 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
color: AppTheme.glassFill(theme.brightness),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -7,6 +7,7 @@ import '../models/models.dart';
import '../services/api_client.dart';
import '../services/ws_client.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
@ -141,7 +142,7 @@ class _ChatScreenState extends State<ChatScreen> {
try {
final contextLines = _messages
.where((m) => !m.retracted)
.map((m) => '${m.isTwin ? "분신" : "상대"}: ${m.text}')
.map((m) => '${m.isTwin ? "와카뷰" : "상대"}: ${m.text}')
.toList();
if (_input.text.trim().isNotEmpty) {
contextLines.add('상대: ${_input.text.trim()}');
@ -190,7 +191,7 @@ class _ChatScreenState extends State<ChatScreen> {
});
_scrollToEnd();
} on ApiException catch (e) {
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}');
} finally {
setState(() => _busy = false);
}
@ -209,7 +210,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context,
builder: (ctx) => AlertDialog(
title: const Text('거부권을 쓸까요?'),
content: const Text('이 대화방에서 분신 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
content: const Text('이 대화방에서 와카뷰 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')),
@ -220,7 +221,7 @@ class _ChatScreenState extends State<ChatScreen> {
final session = context.read<SessionState>();
try {
await session.api.vetoConversation(widget.conversationId);
setState(() => _banner = '거부권 적용: 이 대화방에서 분신 자동응대가 중단됩니다.');
setState(() => _banner = '거부권 적용: 이 대화방에서 와카뷰 자동응대가 중단됩니다.');
} on ApiException catch (e) {
setState(() => _banner = '거부권 실패 (${e.statusCode})');
}
@ -245,8 +246,9 @@ class _ChatScreenState extends State<ChatScreen> {
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
color: theme.colorScheme.errorContainer.withOpacity(0.85),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -263,7 +265,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
const SizedBox(height: 6),
Text(
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
draft.text.isEmpty ? '민감·확정성 내용으로 와카뷰 발송이 보류되었습니다.' : draft.text,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
const SizedBox(height: 4),
@ -280,8 +282,9 @@ class _ChatScreenState extends State<ChatScreen> {
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
color: theme.colorScheme.secondaryContainer.withOpacity(0.85),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -336,7 +339,7 @@ class _ChatScreenState extends State<ChatScreen> {
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [
IconButton(
tooltip: '거부권 (분신 자동응대 중단)',
tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto,
icon: const Icon(Icons.block),
),

View File

@ -103,7 +103,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('분신'),
title: const Text('와카뷰'),
actions: [
IconButton(
tooltip: '사후 알림',
@ -213,7 +213,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
const SizedBox(width: 4),
Text(
'상대가 분신을 거부함',
'상대가 와카뷰를 거부함',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
),
],

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
/// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
class DataFlowScreen extends StatelessWidget {
@ -19,8 +20,9 @@ class DataFlowScreen extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
color: AppTheme.glassFill(theme.brightness),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -58,8 +58,8 @@ class _InboxScreenState extends State<InboxScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Text(
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
'와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. '
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),

View File

@ -69,7 +69,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
),
const SizedBox(height: 16),
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall),
Text('와카뷰가 따라 쓸 말투', style: theme.textTheme.headlineSmall),
const SizedBox(height: 8),
Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '

View File

@ -47,13 +47,13 @@ class _SignupScreenState extends State<SignupScreen> {
),
const SizedBox(height: 20),
Text(
'분신',
'와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'나를 대신해 답하는, 나만의 분신',
'나를 대신해 답하는, 나만의 와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
),

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
/// Shown while `SessionState.restore()` runs, before the real app (signup /
/// onboarding / conversation list) is ready to pick. Purely presentational
/// no navigation logic lives here, `main.dart` swaps it out once restore()
/// resolves.
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: scheme.primary.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary, scheme.tertiary],
),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.auto_awesome, color: Colors.white, size: 30),
),
),
),
const SizedBox(height: 24),
Text(
'와카뷰',
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'나를 대신해 답하는, 나만의 와카뷰',
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 32),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: scheme.primary),
),
],
),
),
);
}
}

View File

@ -1,36 +1,74 @@
import 'package:flutter/material.dart';
/// Central design tokens for (Bunsin). One seed color drives the whole
/// Material 3 palette; `twinAccent` is a separate warm accent used only for
/// the AI-authored badge/border so a twin-written bubble never reads as
/// something the human actually typed (PRD §3.1 ).
/// Central design tokens for (Ykavu) soft-gradient + glassmorphism
/// direction. `backgroundGradient` paints behind every screen (wired into
/// `MaterialApp.builder`); cards/inputs/app bars are semi-transparent
/// "glass" surfaces that let the gradient read through. `twinAccent` stays a
/// separate warm accent so a twin-written bubble never reads as something
/// the human actually typed (PRD §3.1 ).
class AppTheme {
AppTheme._();
static const _seed = Color(0xFF1F6F5B);
static const _seed = Color(0xFF7C6FF0);
static Color twinAccent(Brightness brightness) =>
brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800;
/// Soft diagonal gradient painted behind every screen. Light: pastel
/// lavender sky pink. Dark: desaturated indigo navy plum.
static LinearGradient backgroundGradient(Brightness brightness) {
if (brightness == Brightness.dark) {
return const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1B1730), Color(0xFF161C34), Color(0xFF2A1830)],
);
}
return const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFEDE9FE), Color(0xFFE0F2FE), Color(0xFFFCE7F3)],
);
}
/// "Glass" fill for cards/panels semi-transparent so the gradient
/// behind still reads through, with a faint light border for the classic
/// glassmorphism edge highlight.
static Color glassFill(Brightness brightness) => brightness == Brightness.dark
? Colors.white.withOpacity(0.06)
: Colors.white.withOpacity(0.55);
static Color glassBorder(Brightness brightness) => brightness == Brightness.dark
? Colors.white.withOpacity(0.10)
: Colors.white.withOpacity(0.65);
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
final glass = glassFill(brightness);
final glassEdge = glassBorder(brightness);
final glassShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: glassEdge, width: 1),
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
// Transparent so the gradient painted by MaterialApp.builder shows
// through behind every Scaffold.
scaffoldBackgroundColor: Colors.transparent,
visualDensity: VisualDensity.comfortable,
textTheme: _textTheme(),
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
backgroundColor: Colors.transparent,
foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 1,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontSize: 20,
@ -41,8 +79,8 @@ class AppTheme {
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: glass,
shape: glassShape,
margin: EdgeInsets.zero,
),
listTileTheme: ListTileThemeData(
@ -53,15 +91,15 @@ class AppTheme {
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest,
fillColor: glass,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
borderSide: BorderSide(color: glassEdge, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
borderSide: BorderSide(color: glassEdge, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
@ -102,14 +140,18 @@ class AppTheme {
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
backgroundColor: glass,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: glassEdge, width: 1),
),
side: BorderSide.none,
backgroundColor: scheme.surfaceContainerHighest,
backgroundColor: glass,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
),
@ -124,10 +166,13 @@ class AppTheme {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
bannerTheme: MaterialBannerThemeData(
backgroundColor: scheme.surfaceContainerHigh,
backgroundColor: glass,
padding: const EdgeInsets.all(16),
),
dialogTheme: DialogThemeData(
backgroundColor: brightness == Brightness.dark
? const Color(0xFF211D3D)
: Colors.white.withOpacity(0.92),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),

View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Paints the app-wide soft gradient behind [child]. Wired into
/// `MaterialApp.builder` so every screen gets it automatically without each
/// Scaffold needing its own background screens just need a transparent
/// `scaffoldBackgroundColor` (set globally in [AppTheme]).
class GradientBackdrop extends StatelessWidget {
const GradientBackdrop({super.key, required this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(Theme.of(context).brightness)),
child: child,
);
}
}

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../models/models.dart';
import '../theme/app_theme.dart';
/// Twin messages get a dashed border + badge (PRD §3.1 ) so an
/// Twin messages get a dashed border + badge (PRD §3.1 ) so an
/// auto-sent bubble never reads as something the human actually typed.
class MessageBubble extends StatelessWidget {
const MessageBubble({
@ -69,7 +69,7 @@ class MessageBubble extends StatelessWidget {
Icon(Icons.auto_awesome, size: 13, color: accent),
const SizedBox(width: 4),
Text(
'분신',
'와카뷰',
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,

View File

@ -5,8 +5,8 @@ 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);
await tester.pumpWidget(YkavuApp(session: session));
expect(find.text('와카뷰'), findsOneWidget);
expect(find.text('시작하기'), findsOneWidget);
});
}

View File

@ -23,13 +23,13 @@
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="bunsin_mobile">
<meta name="apple-mobile-web-app-title" content="Ykavu">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>bunsin_mobile</title>
<title>와카뷰 (Ykavu)</title>
<link rel="manifest" href="manifest.json">
</head>
<body>

View File

@ -1,11 +1,11 @@
{
"name": "bunsin_mobile",
"short_name": "bunsin_mobile",
"name": "와카뷰 (Ykavu)",
"short_name": "와카뷰",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"background_color": "#7C6FF0",
"theme_color": "#7C6FF0",
"description": "나를 대신해 답하는, 나만의 와카뷰",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [