제품명 변경(분신→와카뷰) + 소프트 그라디언트/글래스모피즘 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:
parent
6eceef44f8
commit
ebf333d287
10
AGENTS.md
10
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# hikikomori / 분신 (가칭)
|
||||
# hikikomori / 와카뷰 (가칭)
|
||||
|
||||
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신".
|
||||
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
|
||||
|
||||
## 구조
|
||||
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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의 규칙 기반 하드 게이트로, 이미 걸러진
|
||||
내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 분신 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
|
||||
# 와카뷰 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
|
||||
|
||||
**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다
|
||||
(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
|
||||
|
||||
## 아직 없는 것 (다음 워크스트림)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. 다음 액션 체크리스트
|
||||
|
|
|
|||
30
docs/PRD.md
30
docs/PRD.md
|
|
@ -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 최소 버전 필요) |
|
||||
| 사용자가 여러 상대에게 다른 자율성 레벨을 원함 | 상대별 예외 설정으로 지원 (전역 기본값 + 오버라이드) |
|
||||
| 온보딩 시 임포트할 기존 대화가 없음 | 질문 기반 초기 세팅 경로로 폴백 |
|
||||
|
|
|
|||
|
|
@ -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은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||
- 베타 참가자 모집 규모와 방식
|
||||
|
|
|
|||
|
|
@ -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 확정 외)
|
||||
|
|
|
|||
|
|
@ -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. 이런 와카뷰와 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
|
||||
|
||||
## 참가자 확보 규모 (재확인)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
| 지표 | 기준 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)은 이 시점 이후 별도 트랙으로 검토
|
||||
|
||||
## 명시적으로 지금 계획하지 않는 것
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 설계가
|
||||
틀린 것이다.
|
||||
|
||||
## 대상 및 규모
|
||||
|
|
|
|||
|
|
@ -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라면, 와카뷰는 나를 대신해 남과 대화하는 나.
|
||||
- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은
|
||||
어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Reference in New Issue