Compare commits

..

4 Commits

Author SHA1 Message Date
Cursor Agent 2eed4e492c
docs: add deploy/residual checklist (N1–N5) after Phase 1 A–C
Consolidate Claude + Cursor DONE state and the next execution track
(smoke → Docker msn.iykyka.com → stabilize → FCM/Android QA → human PoC).
Sync stale PLANNING Q1–Q7/stack status and point AGENTS/README/CLAUDE/roadmap
at docs/deploy-checklist.md.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-31 01:54:55 +00:00
Claude ce2e38e566
chore: refresh mobile/pubspec.lock after flutter pub get
Transitive dependency version bumps picked up while verifying the merge
actually builds with a real Flutter SDK (analyze/test/build web all pass).
2026-07-31 01:41:45 +00:00
Claude 6743bf0054
Merge origin/main: keep gradient/glassmorphism UI, port demo-invite + boot hardening, purge 분신 branding
- Resolve visual-direction conflicts (app_theme, main.dart, signup/onboarding
  screens, index.html) in favor of the soft-gradient + glassmorphism design;
  drop the competing Twin Shadow palette and TwinTokens usage.
- Port non-visual additions from the parallel branch: CORS middleware,
  FlutterError/PlatformDispatcher crash handlers + boot timeout/fallback
  screen in main.dart, and the shared demo-invite feature
  (core-backend/demo.go, demo_test.go, mobile/lib/config.dart), reskinning
  the demo panel to match the glass UI.
- Rename the shared demo invite code DEMO-BUNSIN -> DEMO-YKAVU on both
  client and server so the tester-facing feature keeps working.
- Purge remaining "분신"/"bunsin" identifiers app-wide: Dart package name
  (bunsin_mobile -> ykavu_mobile), Android applicationId/namespace
  (com.bunsin.bunsin_mobile -> com.ykavu.ykavu_mobile, incl. Kotlin source
  dir move), on-device DB filename, keystore alias/docs, PoC draft-generator
  system prompt, and the static web boot placeholder div.
2026-07-31 01:30:50 +00:00
Claude ebf333d287
제품명 변경(분신→와카뷰) + 소프트 그라디언트/글래스모피즘 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는 전부 통과.
2026-07-31 01:15:42 +00:00
58 changed files with 851 additions and 717 deletions

View File

@ -5,7 +5,7 @@ GEMINI_API_KEY=
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
ADMIN_API_TOKEN=
# Shared demo invite DEMO-BUNSIN on signup UI (multiple testers). Set 0 in production.
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 in production.
ALLOW_DEMO_INVITE=1
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).

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.
@ -90,6 +90,9 @@ changes it.
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
"Phase 1 상세 작업 분해" checklist for what's already done and what's next.
- After Phase 1 A~C, use [`docs/deploy-checklist.md`](docs/deploy-checklist.md)
(N1→N5) for smoke, Docker deploy, stabilize, FCM/Android QA, then human PoC.
Keep that file and `roadmap.md` in sync when status changes.
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
without a reason, and note the reason in the checklist if you do.
- When a task is finished, check it off in that same checklist. When you
@ -113,6 +116,24 @@ Do not invent frameworks, folder layouts, or CI conventions beyond what
in sync.
- Prefer updating existing docs over creating parallel overlapping docs.
## Git remotes & branch policy (Master)
Master policy for this repo — follow even when a cloud agent default suggests
feature branches or a non-`main` base:
1. **Work on `main`.** Commit and land changes on `main` (fast-forward or merge
into `main`). Do not leave finished work only on long-lived side branches
unless Master explicitly asks for a temporary branch.
2. **Dual remote sync after every `main` update.**
- `origin` = GitHub `o0kuma/hikikomori`
- `gitea` = Gitea `gitea.iykyka.com/oh/iykyka` (iykyka)
- Push both: `./scripts/push-both.sh main` (or `git push origin main` then
`git push gitea main`).
3. Never commit secrets (tokens, API keys). Gitea/GitHub credentials stay in
local/env only.
4. If a PR was opened for tooling reasons, merge it into `main` and dual-push;
then close the PR. Keep `gitea/main` even with `origin/main`.
## Communication with agents
- Read the relevant docs before proposing product/tech changes.

View File

@ -1,16 +1,21 @@
# Claude Code — hikikomori / 분신
# Claude Code — hikikomori / 와카뷰
Follow the project instructions in [@AGENTS.md](./AGENTS.md).
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: **분신** (가칭 확정).
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`).
**Next execution track:** N1 smoke → N2 Docker (`msn.iykyka.com`) → N3 stabilize →
N4 FCM/Android QA → **N5 / D human PoC last** — see
[`docs/deploy-checklist.md`](./docs/deploy-checklist.md). Do not start human PoC
early or invent Phase 1 §3 defaults.
- 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.
- Never weaken escalation, twin badge, peer veto, or undo.
- **Git:** work and land on `main`; after each push to GitHub `origin`, also push
`main` to Gitea `gitea` (`scripts/push-both.sh`). See `AGENTS.md`.
Before changing product or technical direction, read `AGENTS.md` and the
relevant files under `docs/`.

View File

@ -1,6 +1,6 @@
# hikikomori / 분신 (가칭)
# hikikomori / 와카뷰 (가칭)
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신".
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
## 구조
@ -18,17 +18,21 @@
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
## AI 에이전트 규칙
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
- **브랜치:** 작업·머지는 항상 `main`
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
## 현재 단계
- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막)
- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) — N1 스모크 → N2 `msn.iykyka.com` Docker → N3 안정화 → N4 FCM/Android QA
- **맨 마지막:** N5 / D — 사람 PoC #1/#3·Q3 인터뷰 (`docs/roadmap.md` §3). §3 기본값 추측 금지
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
## 로컬 실행 (요약)

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

@ -12,7 +12,7 @@ import (
// Shared closed-beta demo invite shown on the Flutter signup screen.
// Multiple testers can use the same code when ALLOW_DEMO_INVITE is enabled.
const demoInviteCode = "DEMO-BUNSIN"
const demoInviteCode = "DEMO-YKAVU"
func demoInviteEnabled() bool {
v := strings.TrimSpace(os.Getenv("ALLOW_DEMO_INVITE"))
@ -84,7 +84,7 @@ func demoSignupInviteCode(db *gorm.DB) (InviteCode, error) {
}
// uniqueDemoUserInvite stores a per-user value on users.invite_code (unique)
// while still accepting the shared DEMO-BUNSIN input.
// while still accepting the shared DEMO-YKAVU input.
func uniqueDemoUserInvite() (string, error) {
suffix, err := generateInviteCode()
if err != nil {

View File

@ -129,7 +129,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.
@ -164,7 +164,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전
// 방식은 "초대 기반"이 아니었음.
//
// Shared demo: DEMO-BUNSIN (ALLOW_DEMO_INVITE, default on) can be reused
// Shared demo: DEMO-YKAVU (ALLOW_DEMO_INVITE, default on) can be reused
// by multiple testers; User.InviteCode still gets a unique stored value.
demo := demoInviteEnabled() && isDemoInviteCode(req.InviteCode)
var invite InviteCode
@ -258,7 +258,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
}
@ -278,7 +278,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,
@ -298,7 +298,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 와카뷰로 좁혀 설계 심화"까지 진행됐다.
즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다.
이 단계에서 실패하는 가장 흔한 패턴은 두 가지다.
@ -29,18 +29,18 @@
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다.
회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다.
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
| # | 질문 | 잠정 결정 | 상태 |
| # | 질문 | 결정 | 상태 |
|---|------|------|------|
| Q1 | AI 분신으로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 |
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 |
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 |
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 |
| Q6 | 서비스 이름 | "분신" (가칭) | 제안 (가칭) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 |
| Q1 | AI 와카뷰로 확정? | 예 | **확정** `decision-log.md` |
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | **확정** |
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 (시작 기본값은 PoC 후) | **확정** (하위 기본값은 열림) |
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | **확정** (실사용은 PoC) |
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | **확정** |
| Q6 | 서비스 이름 | "와카뷰 (Ykavu)" | **확정** (2026-07-31 명칭 변경) |
| 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. 다음 액션 체크리스트
@ -143,14 +143,17 @@
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
"말투 검색기" 참고)
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`).
4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`ykavu-prototype`).
PoC #3 역할극 자극재로 사용
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) — **맨 마지막(N5/D)**
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정
- [x] Q1~Q7 정식 확정 — `decision-log.md` (Phase 1 C, 2026-07-30). PoC 의존 하위만 열림
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md`
Phase 1 §1, 회의 필요
- [x] 기술 스택 결정 — `tech-design.md` §8 / `roadmap.md` Phase 1 §1
(Flutter/Dart, Go core + Python AI, WebSocket, drift+SQLCipher; 배포 DB는
[`deploy-checklist.md`](./deploy-checklist.md) N2-A에서 SQLite 1차 등 운영 결정)
- [ ] A~C 이후 실행 트랙 — [`deploy-checklist.md`](./deploy-checklist.md)
(N1 스모크 → N2 Docker → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC)

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

@ -16,7 +16,7 @@ v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아
2. Master 머신에서 한 번 생성:
```bash
keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin
keytool -genkey -v -keystore ~/ykavu-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ykavu
```
3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성:
@ -24,8 +24,8 @@ keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -val
```
storePassword=...
keyPassword=...
keyAlias=bunsin
storeFile=/absolute/path/to/bunsin-release.jks
keyAlias=ykavu
storeFile=/absolute/path/to/ykavu-release.jks
```
4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다

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은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
- 베타 참가자 모집 규모와 방식

183
docs/deploy-checklist.md Normal file
View File

@ -0,0 +1,183 @@
# 배포·잔여 작업 체크리스트 (와카뷰)
Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
권위 문서: [`roadmap.md`](./roadmap.md) · [`decision-log.md`](./decision-log.md) · [`AGENTS.md`](../AGENTS.md).
**사람 PoC(D)와 Phase 1 §3 기본값은 맨 마지막.** 추측으로 채우지 않는다.
---
## 0. 통합 현황 (Claude + Cursor)
### DONE — Claude (`claude/project-planning-approach-ukdz31` 계열)
- [x] 기획 문서 세트 (`PLANNING`, vision/PRD/tech-design/risk/roadmap, decision-log 초안)
- [x] PoC #1/#3 계획·모집/역할극 자료·Q3 인터뷰 가이드·프로토타입 앵커
- [x] `poc/tone-corpus/` 파이프라인 (전처리·draft·escalation·retrieve·blind_eval)
- [x] 스택 확정: Flutter + Go core + Python AI
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
### DONE — Cursor 후속
- [x] A1/A2 API · A3 Flutter 메신저 연결 · API E2E (`scripts/e2e_a3.py`)
- [x] Phase 1 B (drift/SQLCipher, FCM 골격, sessions, metrics, data-flow, identity)
- [x] Phase 1 C (Q1~Q7 **확정**, invite-ops, Android release 경로)
- [x] Flutter Web SQLCipher stub · Twin Shadow UI · CORS · `DEMO-BUNSIN`
- [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`)
### NOW
- 앱 코드는 클로즈드 베타 직전 수준
- **프로덕션 Docker / `msn.iykyka.com` 배포는 미착수**
- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음
### NEXT 순서
```
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
```
### LOCKED
- [ ] Phase 2+ (L3 / OS 레이어 / L4 / B2B) — Phase 1 게이트 전 구현 금지
- [ ] PoC §3 기본값(자율성 시작 레벨, 화이트리스트 기본 주제, 신뢰 UX 최종 카피) 추측 금지
---
## 항목 템플릿
각 ID를 처리할 때 아래로 상태를 갱신한다.
```text
Status: todo | doing | done | blocked
Depends on:
Acceptance: (체크리스트)
Notes:
```
---
## N1 — 배포 전 스모크
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N1-1** | 서비스 기동 | todo | `8080` core-backend, `8001` ai-service, (옵션) `5555` Flutter web 헬스 OK |
| **N1-2** | API E2E | todo | `ADMIN_API_TOKEN` 설정 후 `python3 scripts/e2e_a3.py` 16/16 |
| **N1-3** | Web 가입 스모크 | todo | `DEMO-BUNSIN` → 표시명 → 가입 → 세션/다음 화면 |
| **N1-4** | 말투 온보딩 스모크 | todo | 샘플 저장 또는 스킵 후 대화 목록 진입 |
| **N1-5** | 핵심 메신저 스모크 | todo | 연락처·대화·메시지 또는 초안/L1 중 최소 1경로 UI 왕복 |
| **N1-6** | 프로덕션 CORS/API 메모 | todo | `msn.iykyka.com`용 origins / `CORE_API_BASE` 변경 목록 작성 |
로컬 포트 참고: 앱 **5555**, 코어 **8080**, AI **8001**. Dart VM Service 고포트(예: 39369)는 디버그용 — 무시 가능.
---
## N2 — Docker 배포 (`https://msn.iykyka.com`) — Plan A
### N2-A. 착수 전 결정 (Master 확인)
| ID | 결정 | 제안 기본값 | Status |
|----|------|-------------|--------|
| **N2-A1** | 구 Node MSN 교체 | 교체(Plan A) | todo |
| **N2-A2** | DB | 1차 SQLite 파일 볼륨 (PostgreSQL은 이후) | todo |
| **N2-A3** | AI 서비스 노출 | 내부망만 (외부 포트 미공개) | todo |
| **N2-A4** | 클라이언트 제공 | Web 컨테이너 ± 내부 APK (둘 다/웹만 결정) | todo |
| **N2-A5** | 시크릿 관리 | Portainer/호스트 env — **git 금지** | todo |
| **N2-A6** | 데모 초대 | 프로덕션 `ALLOW_DEMO_INVITE` on/off | todo |
### N2-B. 이미지·compose
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 |
| **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 |
| **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 |
| **N2-B4** | `docker-compose.yml` | todo | `up` 후 서비스 healthy |
| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, DB, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` |
| **N2-B6** | 데이터 볼륨 | todo | 재시작 후 SQLite(및 필요 경로) 유지 |
| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate 동작 |
| **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 |
| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 |
| **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 |
| **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 |
| **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 |
| **N2-B13** | 운영 runbook | todo | 로그·재시작·SQLite 백업·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) |
관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지).
---
## N3 — 배포 직후 안정화
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N3-1** | 헬스/로그 | todo | OOM·CORS·502 없음 |
| **N3-2** | 초대 발급 리허설 | todo | `docs/invite-ops.md` 절차 1회 |
| **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 |
| **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 |
| **N3-5** | 백업 리허설 | todo | SQLite 볼륨 복사/복구 1회 |
| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-BUNSIN` 또는 개인 코드) + 주의사항 |
---
## N4 — 베타 품질 잔여
### FCM
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 |
| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 |
| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) |
| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 |
### Android UI 탭 (`mobile/README.md`)
| ID | 작업 | Status |
|----|------|--------|
| **N4-5** | 가입 → 말투 저장 | todo |
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo |
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo |
| **N4-8** | 에스컬레이션 → 사후알림 함 | todo |
| **N4-9** | 되돌리기·거부권 | todo |
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo |
### 후순위
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
---
## N5 — 사람 PoC (D) — **지금 열지 않음**
N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` Phase 1 §3과 동일.
| ID | 작업 | Status |
|----|------|--------|
| **N5-1** | PoC #1/#3 참가자 모집·실행 | locked |
| **N5-2** | Q3 인터뷰 → 자율성 기본값 | locked |
| **N5-3** | 화이트리스트 기본 주제 | locked |
| **N5-4** | 신뢰 UX 문구/위치 확정 | locked |
| **N5-5** | vision 게이트 → 실제 클로즈드 베타 오픈 | locked |
자료: [`poc-plan.md`](./poc-plan.md) · [`poc-materials.md`](./poc-materials.md) · [`user-interview-guide.md`](./user-interview-guide.md).
---
## 바로 다음 5개 (권장)
1. **N2-A1~A6** 결정 체크
2. **N1-1~N1-5** 스모크
3. **N2-B1~B4** Dockerfile + compose
4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크
5. **N3-6** 테스터 안내
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

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개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요
@ -32,7 +32,7 @@
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
`ykavu-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
@ -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

@ -7,7 +7,7 @@ Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식
| 항목 | 값 |
|------|-----|
| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) |
| **식별자** | `ykavu-prototype` (`poc-materials.md` §2와 동일) |
| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) |
| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 |
@ -33,4 +33,4 @@ SHARE_URL=
1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다.
2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다.
3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다.
3. 호스팅을 옮겨도 식별자 `ykavu-prototype`과 이 앵커 문서는 유지한다.

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
@ -212,10 +212,19 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
- §3 항목과 동일. A~C 완료 후에만 착수.
**단, 프로덕션 Docker 배포·스모크(N1~N3)는 D보다 앞** — [`deploy-checklist.md`](./deploy-checklist.md).
##### E. 베타 이후 (지금은 설계만, 구현 금지)
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
#### 6. 배포·잔여 작업 (A~C 이후 실행 트랙)
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
## Phase 2 — L3 확장 + 베타 확대
@ -231,10 +240,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

@ -16,7 +16,7 @@ if (hasReleaseKeystore) {
}
android {
namespace = "com.bunsin.bunsin_mobile"
namespace = "com.ykavu.ykavu_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@ -30,7 +30,7 @@ android {
}
defaultConfig {
applicationId = "com.bunsin.bunsin_mobile"
applicationId = "com.ykavu.ykavu_mobile"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode

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

@ -1,4 +1,4 @@
package com.bunsin.bunsin_mobile
package com.ykavu.ykavu_mobile
import io.flutter.embedding.android.FlutterActivity

View File

@ -1,4 +1,4 @@
storePassword=replace-me
keyPassword=replace-me
keyAlias=bunsin
storeFile=/absolute/path/to/bunsin-release.jks
keyAlias=ykavu
storeFile=/absolute/path/to/ykavu-release.jks

View File

@ -10,7 +10,7 @@ class AppConfig {
/// Shared demo invite (must match core-backend `demoInviteCode`).
/// Shown on the signup screen so other testers can join without admin mint.
static const demoInviteCode = 'DEMO-BUNSIN';
static const demoInviteCode = 'DEMO-YKAVU';
static const demoDisplayName = '테스터';

View File

@ -83,7 +83,7 @@ const _kDbPassphrase = 'db_passphrase_v1';
QueryExecutor _openEncryptedExecutor() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
final file = File(p.join(dir.path, 'ykavu_encrypted.db'));
final passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.

View File

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -7,10 +5,12 @@ 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();
FlutterError.onError = (details) {
@ -23,30 +23,83 @@ Future<void> main() async {
return true;
};
runApp(const _Bootstrap());
}
/// 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. Falls back to a readable
/// error screen if restore fails or hangs, rather than crashing silently.
class _Bootstrap extends StatefulWidget {
const _Bootstrap();
@override
State<_Bootstrap> createState() => _BootstrapState();
}
class _BootstrapState extends State<_Bootstrap> {
SessionState? _session;
Object? _error;
StackTrace? _stackTrace;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final session = SessionState();
await session.restore().timeout(const Duration(seconds: 8));
runApp(BunsinApp(session: session));
if (!mounted) return;
setState(() => _session = session);
} catch (e, st) {
debugPrint('BOOT FAIL: $e\n$st');
runApp(
MaterialApp(
if (!mounted) return;
setState(() {
_error = e;
_stackTrace = st;
});
}
}
@override
Widget build(BuildContext context) {
final error = _error;
if (error != null) {
return MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: SelectableText('앱 시작 실패\n\n$e\n\n$st'),
),
child: SelectableText('앱 시작 실패\n\n$error\n\n${_stackTrace ?? ''}'),
),
),
),
);
}
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 BunsinApp extends StatelessWidget {
const BunsinApp({super.key, required this.session});
class YkavuApp extends StatelessWidget {
const YkavuApp({super.key, required this.session});
final SessionState session;
@ -55,11 +108,12 @@ class BunsinApp extends StatelessWidget {
return ChangeNotifierProvider.value(
value: session,
child: MaterialApp(
title: '분신',
title: '와카뷰',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
// Twin Shadow phase-1: light only (dark kept in AppTheme for later).
themeMode: ThemeMode.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,13 +103,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
return Scaffold(
appBar: AppBar(
title: Text(
'분신',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
),
),
title: const Text('와카뷰'),
actions: [
IconButton(
tooltip: '사후 알림',
@ -167,13 +161,10 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
session.user == null ? '' : '${session.user!.displayName}의 대화',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
if (_error != null)
@ -222,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

@ -20,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,
@ -66,7 +67,7 @@ class DataFlowScreen extends StatelessWidget {
_section(
context,
icon: Icons.lock_outline,
tint: TwinTokens.forest,
tint: Colors.green.shade600,
title: '기기 안에만 둡니다',
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
@ -90,7 +91,7 @@ class DataFlowScreen extends StatelessWidget {
_section(
context,
icon: Icons.cloud_upload_outlined,
tint: TwinTokens.twinMark,
tint: Colors.amber.shade800,
title: '서버로 보낼 수 있는 것',
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n'

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

@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/twin_hero_backdrop.dart';
/// Phase 1 onboarding: capture a few style samples locally.
/// Fine copy / import UX waits for human PoC do not invent §3 defaults here.
/// Phase 1 onboarding skeleton: capture a few style samples locally.
/// Fine copy / import UX waits for human PoC (#1) do not invent §3 defaults here.
class OnboardingToneScreen extends StatefulWidget {
const OnboardingToneScreen({super.key});
@ -49,84 +47,54 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final fromMenu = Navigator.of(context).canPop();
return Scaffold(
body: TwinHeroBackdrop(
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Row(
children: [
if (fromMenu)
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back),
)
else
const SizedBox(width: 48),
const Spacer(),
appBar: AppBar(
title: const Text('말투 샘플'),
actions: [
TextButton(
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
child: const Text('나중에'),
),
const SizedBox(width: 8),
],
),
),
Expanded(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
padding: const EdgeInsets.all(24),
children: [
TwinFadeUp(
child: Text(
'말투',
style: theme.textTheme.labelLarge?.copyWith(
color: TwinTokens.forest,
letterSpacing: 1.2,
),
),
Container(
width: 56,
height: 56,
decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
),
const SizedBox(height: 16),
Text('와카뷰가 따라 쓸 말투', style: theme.textTheme.headlineSmall),
const SizedBox(height: 8),
TwinFadeUp(
delay: const Duration(milliseconds: 80),
child: Text(
'분신이 따라 쓸 말투',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: TwinTokens.ink,
),
),
),
const SizedBox(height: 10),
TwinFadeUp(
delay: const Duration(milliseconds: 140),
child: Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.',
Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
),
),
const SizedBox(height: 28),
for (var i = 0; i < _samples.length; i++) ...[
TwinFadeUp(
delay: Duration(milliseconds: 180 + i * 50),
child: TextField(
TextField(
controller: _samples[i],
maxLines: 2,
decoration: InputDecoration(
labelText: '샘플 ${i + 1}',
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 12, right: 4),
child: Align(
widthFactor: 1,
padding: const EdgeInsets.only(left: 4, right: 4, top: 4),
child: CircleAvatar(
radius: 12,
backgroundColor: scheme.secondaryContainer,
child: Text(
'${i + 1}',
style: theme.textTheme.titleSmall?.copyWith(
color: TwinTokens.forest,
fontWeight: FontWeight.w800,
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: scheme.onSecondaryContainer,
),
),
),
@ -135,20 +103,14 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
),
const SizedBox(height: 12),
],
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 20),
child: FilledButton(
const SizedBox(height: 12),
FilledButton(
onPressed: () => _save(markDone: true),
child: const Text('이 말투로 시작'),
),
),
],
),
),
),
);
}
}

View File

@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
import '../config.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/twin_hero_backdrop.dart';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@ -36,54 +35,41 @@ class _SignupScreenState extends State<SignupScreen> {
final session = context.watch<SessionState>();
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
body: TwinHeroBackdrop(
child: SafeArea(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(flex: 2),
TwinFadeUp(
child: Text(
'분신',
const Spacer(flex: 3),
Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(22),
),
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
),
),
const SizedBox(height: 20),
Text(
'와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.displayLarge?.copyWith(
fontSize: 56,
fontWeight: FontWeight.w800,
color: TwinTokens.ink,
letterSpacing: -1.6,
),
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'나를 대신해 답하는, 나만의 와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
),
const Spacer(flex: 2),
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
const SizedBox(height: 12),
TwinFadeUp(
delay: const Duration(milliseconds: 90),
child: Text(
'나를 대신해 답하는, 나만의 그림자',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
color: TwinTokens.forestDeep,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
),
const SizedBox(height: 10),
TwinFadeUp(
delay: const Duration(milliseconds: 160),
child: Text(
'초대 코드로 클로즈드 베타에 참여합니다',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
),
),
const Spacer(flex: 2),
TwinFadeUp(
delay: const Duration(milliseconds: 200),
child: _DemoTestPanel(
_DemoTestPanel(
onFill: _fillDemoCredentials,
onCopy: () async {
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
@ -93,18 +79,12 @@ class _SignupScreenState extends State<SignupScreen> {
);
},
),
),
const SizedBox(height: 16),
TwinFadeUp(
delay: const Duration(milliseconds: 240),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _invite,
decoration: const InputDecoration(
labelText: '초대 코드',
prefixIcon: Icon(Icons.vpn_key_outlined),
prefixIcon: Icon(Icons.vpn_key),
),
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.characters,
@ -134,29 +114,21 @@ class _SignupScreenState extends State<SignupScreen> {
? null
: () => session.signup(_invite.text.trim(), _name.text.trim()),
child: session.loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('시작하기'),
),
),
const SizedBox(height: 32),
],
),
),
const SizedBox(height: 28),
],
),
),
),
),
);
}
}
/// Visible test credentials so other people can try the closed beta without
/// asking Master for a one-off invite mint.
/// asking for a one-off invite mint.
class _DemoTestPanel extends StatelessWidget {
const _DemoTestPanel({required this.onFill, required this.onCopy});
@ -166,21 +138,26 @@ class _DemoTestPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final brightness = theme.brightness;
return Material(
color: TwinTokens.mist.withValues(alpha: 0.85),
color: AppTheme.glassFill(brightness),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onFill,
borderRadius: BorderRadius.circular(16),
child: Padding(
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(brightness)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'테스트용 (누구나)',
style: theme.textTheme.labelLarge?.copyWith(
color: TwinTokens.forest,
color: theme.colorScheme.primary,
letterSpacing: 0.4,
),
),
@ -191,7 +168,6 @@ class _DemoTestPanel extends StatelessWidget {
child: Text(
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
style: theme.textTheme.titleSmall?.copyWith(
color: TwinTokens.ink,
height: 1.45,
fontWeight: FontWeight.w700,
),
@ -207,7 +183,7 @@ class _DemoTestPanel extends StatelessWidget {
const SizedBox(height: 4),
Text(
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
style: theme.textTheme.bodySmall?.copyWith(color: TwinTokens.ink.withValues(alpha: 0.55)),
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.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,99 +1,105 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Twin Shadow design tokens for .
///
/// Brand-first: forest ink on mist/paper atmosphere. `twinAccent` stays a
/// separate warm mark so AI-authored bubbles never read as human-typed
/// (PRD §3.1 ).
class TwinTokens {
TwinTokens._();
static const ink = Color(0xFF0E1A16);
static const forest = Color(0xFF1F6F5B);
static const forestDeep = Color(0xFF163F34);
static const mist = Color(0xFFE7F0EC);
static const paper = Color(0xFFF5F7F6);
static const glow = Color(0xFFC8E6D9);
static const twinMark = Color(0xFFB8860B); // muted gold AI authorship only
}
/// 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(0xFF7C6FF0);
static Color twinAccent(Brightness brightness) =>
brightness == Brightness.dark ? const Color(0xFFE0C36A) : TwinTokens.twinMark;
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);
/// Phase-1 default: light Twin Shadow only.
static ThemeData light() => _build(Brightness.light);
/// Kept for system/dark opt-in later; not used while ThemeMode.light.
static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final isLight = brightness == Brightness.light;
final baseScheme = ColorScheme.fromSeed(
seedColor: TwinTokens.forest,
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),
);
final scheme = baseScheme.copyWith(
primary: isLight ? TwinTokens.forest : TwinTokens.glow,
onPrimary: isLight ? Colors.white : TwinTokens.ink,
primaryContainer: isLight ? TwinTokens.mist : TwinTokens.forestDeep,
onPrimaryContainer: isLight ? TwinTokens.forestDeep : TwinTokens.mist,
surface: isLight ? TwinTokens.paper : const Color(0xFF0B1210),
onSurface: isLight ? TwinTokens.ink : TwinTokens.mist,
onSurfaceVariant: isLight ? TwinTokens.ink.withValues(alpha: 0.62) : TwinTokens.mist.withValues(alpha: 0.72),
surfaceContainerHighest: isLight ? TwinTokens.mist : const Color(0xFF15201C),
surfaceContainerHigh: isLight ? const Color(0xFFEEF4F1) : const Color(0xFF121A17),
outlineVariant: isLight ? TwinTokens.glow : TwinTokens.forestDeep,
);
final textTheme = _textTheme(scheme);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
visualDensity: VisualDensity.standard,
textTheme: textTheme,
primaryTextTheme: textTheme,
// 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: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0.5,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: scheme.onSurface,
letterSpacing: -0.2,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
color: glass,
shape: glassShape,
margin: EdgeInsets.zero,
),
listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
),
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 24, thickness: 1),
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isLight ? Colors.white.withValues(alpha: 0.72) : scheme.surfaceContainerHighest,
fillColor: glass,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
borderSide: BorderSide(color: glassEdge, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)),
borderSide: BorderSide(color: glassEdge, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
@ -107,30 +113,23 @@ class AppTheme {
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.6),
),
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)),
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: TwinTokens.forest,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, fontSize: 16),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
side: BorderSide(color: scheme.outlineVariant),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: TwinTokens.forest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
@ -139,57 +138,57 @@ class AppTheme {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
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),
side: BorderSide(color: glassEdge, width: 1),
),
side: BorderSide.none,
backgroundColor: glass,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: TwinTokens.forest,
foregroundColor: Colors.white,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
bannerTheme: MaterialBannerThemeData(
backgroundColor: glass,
padding: const EdgeInsets.all(16),
),
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
backgroundColor: brightness == Brightness.dark
? const Color(0xFF211D3D)
: Colors.white.withOpacity(0.92),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: BorderSide.none,
backgroundColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
),
);
}
static TextTheme _textTheme(ColorScheme scheme) {
final base = GoogleFonts.manropeTextTheme();
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -1.2,
color: scheme.onSurface,
height: 1.05,
),
displaySmall: base.displaySmall?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.8,
color: scheme.onSurface,
height: 1.1,
),
headlineSmall: base.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: scheme.onSurface,
),
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
titleSmall: base.titleSmall?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
bodyLarge: base.bodyLarge?.copyWith(height: 1.45, color: scheme.onSurface),
bodyMedium: base.bodyMedium?.copyWith(height: 1.45, color: scheme.onSurface),
bodySmall: base.bodySmall?.copyWith(height: 1.4, color: scheme.onSurfaceVariant),
labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
static TextTheme _textTheme() {
return const TextTheme(
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3),
titleLarge: TextStyle(fontWeight: FontWeight.w700),
titleMedium: TextStyle(fontWeight: FontWeight.w700),
titleSmall: TextStyle(fontWeight: FontWeight.w700),
bodyLarge: TextStyle(height: 1.4),
bodyMedium: TextStyle(height: 1.4),
labelLarge: TextStyle(fontWeight: FontWeight.w700),
);
}
}

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

@ -1,164 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Full-bleed Twin Shadow atmosphere: mistpaper gradient + overlapping
/// silhouettes (the "second self"). No badges, chips, or promo overlays.
class TwinHeroBackdrop extends StatefulWidget {
const TwinHeroBackdrop({super.key, required this.child});
final Widget child;
@override
State<TwinHeroBackdrop> createState() => _TwinHeroBackdropState();
}
class _TwinHeroBackdropState extends State<TwinHeroBackdrop> with SingleTickerProviderStateMixin {
late final AnimationController _drift;
@override
void initState() {
super.initState();
_drift = AnimationController(vsync: this, duration: const Duration(seconds: 14))..repeat(reverse: true);
}
@override
void dispose() {
_drift.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _drift,
builder: (context, _) {
final t = Curves.easeInOut.transform(_drift.value);
return Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(-0.2 + t * 0.15, -1),
end: Alignment(0.3 - t * 0.1, 1.1),
colors: const [
TwinTokens.glow,
TwinTokens.mist,
TwinTokens.paper,
],
stops: const [0.0, 0.42, 1.0],
),
),
),
CustomPaint(
painter: _TwinSilhouettePainter(phase: t),
),
widget.child,
],
);
},
);
}
}
class _TwinSilhouettePainter extends CustomPainter {
_TwinSilhouettePainter({required this.phase});
final double phase;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final paintA = Paint()..color = TwinTokens.forest.withValues(alpha: 0.07 + phase * 0.02);
final paintB = Paint()..color = TwinTokens.ink.withValues(alpha: 0.05 + (1 - phase) * 0.02);
// Soft overlapping ovals a person and their shadow-self.
final a = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w * 0.72 + phase * 8, h * 0.22),
width: w * 0.55,
height: h * 0.42,
),
const Radius.circular(120),
);
final b = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w * 0.78 + phase * 4, h * 0.26),
width: w * 0.48,
height: h * 0.38,
),
const Radius.circular(120),
);
canvas.drawRRect(a, paintA);
canvas.drawRRect(b, paintB);
// Thin crescent arc suggesting a second outline.
final arcPaint = Paint()
..color = TwinTokens.forest.withValues(alpha: 0.12)
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
canvas.drawArc(
Rect.fromCenter(center: Offset(w * 0.78, h * 0.24), width: w * 0.42, height: h * 0.34),
-math.pi * 0.2,
math.pi * 1.1,
false,
arcPaint,
);
}
@override
bool shouldRepaint(covariant _TwinSilhouettePainter oldDelegate) => oldDelegate.phase != phase;
}
/// Brand wordmark entrance fade + slight rise.
class TwinFadeUp extends StatefulWidget {
const TwinFadeUp({
super.key,
required this.child,
this.delay = Duration.zero,
this.duration = const Duration(milliseconds: 700),
});
final Widget child;
final Duration delay;
final Duration duration;
@override
State<TwinFadeUp> createState() => _TwinFadeUpState();
}
class _TwinFadeUpState extends State<TwinFadeUp> with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final Animation<double> _opacity;
late final Animation<Offset> _offset;
@override
void initState() {
super.initState();
_c = AnimationController(vsync: this, duration: widget.duration);
_opacity = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic);
_offset = Tween(begin: const Offset(0, 0.06), end: Offset.zero)
.animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic));
Future<void>.delayed(widget.delay, () {
if (mounted) _c.forward();
});
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: SlideTransition(position: _offset, child: widget.child),
);
}
}

View File

@ -93,10 +93,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
charcode:
dependency: transitive
description:
@ -356,26 +356,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@ -396,26 +396,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@ -713,10 +713,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@ -729,10 +729,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@ -798,5 +798,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.1 <4.0.0"
dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.29.0"

View File

@ -1,4 +1,4 @@
name: bunsin_mobile
name: ykavu_mobile
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.

View File

@ -1,4 +1,4 @@
import 'package:bunsin_mobile/db/app_database.dart';
import 'package:ykavu_mobile/db/app_database.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {

View File

@ -1,4 +1,4 @@
import 'package:bunsin_mobile/models/models.dart';
import 'package:ykavu_mobile/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {

View File

@ -1,12 +1,12 @@
import 'package:bunsin_mobile/main.dart';
import 'package:bunsin_mobile/state/session_state.dart';
import 'package:ykavu_mobile/main.dart';
import 'package:ykavu_mobile/state/session_state.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('shows signup when logged out', (tester) async {
final session = SessionState();
await tester.pumpWidget(BunsinApp(session: session));
expect(find.text('분신'), findsOneWidget);
await tester.pumpWidget(YkavuApp(session: session));
expect(find.text('와카뷰'), findsOneWidget);
expect(find.text('시작하기'), findsOneWidget);
});
}

View File

@ -23,19 +23,16 @@
<!-- 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>분신</title>
<title>와카뷰 (Ykavu)</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<div id="bunsin-boot" style="font-family: Manrope, system-ui, sans-serif; padding: 24px; color: #1F6F5B;">
분신 로딩 중…
</div>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

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": [

View File

@ -51,7 +51,7 @@ def load_dotenv_if_present():
break
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
지켜야 :

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash
# Push main to GitHub (origin) and Gitea (gitea) remotes.
# Push a branch to GitHub (origin) and Gitea iykyka (gitea) remotes.
# Master policy: land work on main, then run: ./scripts/push-both.sh main
# See AGENTS.md "Git remotes & branch policy".
set -euo pipefail
BRANCH="${1:-main}"
cd "$(dirname "$0")/.."