Merge pull request #1 from o0kuma/claude/project-planning-approach-ukdz31

Add planning approach and doc set for AI-twin messenger idea
This commit is contained in:
cursor[bot] 2026-07-30 04:17:03 +00:00 committed by GitHub
commit 6986c4e15f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 7478 additions and 0 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# Copy to .env and fill in values. Never commit .env.
# Get a key from Google AI Studio: https://aistudio.google.com/apikey
GEMINI_API_KEY=

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# PoC data — large, and AI-Hub source data may not be redistributed.
# Keep scripts in git; keep the actual datasets out.
poc/tone-corpus/data/
*.jsonl
*.zip
# Local secrets (API keys). Commit .env.example only.
.env
.env.*
!.env.example
__pycache__/
*.pyc
# Local dev DB (backend/, SQLite fallback for DATABASE_URL)
*.db
# Go build output (core-backend/)
/core-backend/core-backend

119
AGENTS.md Normal file
View File

@ -0,0 +1,119 @@
# Project Instructions — hikikomori / 분신 (가칭)
This repository is currently a **planning-docs repo** for an AI-twin messenger
("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet.
Canonical docs live under `docs/`. Prefer linking to them over copying content
into prompts or new files.
## Document authority
When documents disagree, follow this order:
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
5. [`docs/idea-meeting-2026-06-29.html`](docs/idea-meeting-2026-06-29.html) — historical meeting source only
Notes:
- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting
decisions. Do not silently reverse them. If a change is required, update
`decision-log.md` and all derived docs in the same change.
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
current working answers.
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
(`decision-log.md` Q7). Do not reverse that sequence.
## Product identity (v1 working assumptions)
- Name (working): **분신**
- One-liner: ChatGPT talks *with* me; 분신 talks *as* me *to others*
- Target: consumer individuals first (not B2B)
- Delivery: self-owned messenger, closed beta
- Autonomy for v1: **L0~L2 only**
- MVP scenarios: **읽씹 종결** + **단톡 따라잡기**
- Platform: Android first
## Hard scope guards (do not expand without an explicit decision-log update)
Out of v1 scope:
- L3 full away-mode auto-reply
- L4 twin-to-twin negotiation / appointment auto-finalization
- OS-layer bridging over KakaoTalk / Instagram / etc.
- B2B / workspace products
- Server-side full conversation analytics that violate on-device-first
Do not design or implement Phase 2+ work before Phase 1 gates are validated
(`docs/roadmap.md`).
## Absolute safety invariants
These apply at every autonomy level and must not be weakened for convenience:
- Money, appointment confirmation, and emotional/sensitive topics always
escalate to the human. When uncertain, escalate (fail-safe).
- Twin-authored messages must be visually distinct (badge / `sender_mode`).
- Honest identity answers: if asked “본인이야 분신이야?”, answer as twin.
- Peer veto: if the other person rejects the twin, disable auto-reply for that
conversation immediately.
- Every automatic action needs post-hoc notification + one-tap undo.
## Privacy & architecture principles
- Tone/style learning: **on-device first**. Do not default to uploading raw chat
history to servers.
- Autonomy / escalation engine: **client-side** so safety is not blocked by
server latency or outage.
- Draft generation: on-device first, server LLM fallback with minimal context.
- Relay/storage server is allowed; it is not a license for full cloud analysis.
## Process rules
Follow `docs/PLANNING.md`: decide → narrow → validate → specify.
PoC execution (real participant recruiting for PoC #1/#3, Q3 interviews) is
deferred to the **very last** Phase 1 step — after Flutter client + remaining
server infra are done. Do not start human PoC early and do not invent defaults
for Phase 1 §3. See `docs/roadmap.md` Phase 1 §3/§4.
The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8
(Flutter/Dart client, Go core backend + Python AI service, PostgreSQL,
WebSocket relay, drift+SQLCipher on-device). Do not re-litigate or invent a
different stack; build within this one unless a decision-log-style update
changes it.
### Phase 1 앱 빌드 작업 규칙
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
"Phase 1 상세 작업 분해" checklist for what's already done and what's next.
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
without a reason, and note the reason in the checklist if you do.
- When a task is finished, check it off in that same checklist. When you
discover a new sub-task, add it there rather than tracking it elsewhere.
- Items under Phase 1 §3 ("PoC 결과가 있어야 정할 수 있는 것") stay unresolved
until real PoC data comes in — don't guess a default to unblock yourself;
leave a placeholder and move on to other checklist items instead. Do not
start §3 early even if PoC data happens to arrive mid-way — finish all of
§4's items 1-5 (the rest of the build order) first, then come back to §3.
Do not invent frameworks, folder layouts, or CI conventions beyond what
`docs/tech-design.md` §8 and `docs/roadmap.md` already specify.
## Documentation conventions
- Planning docs are written in **Korean**.
- Keep decisions traceable with links to `decision-log.md` Q# and related files.
- Preserve `idea-meeting-2026-06-29.html` as historical source material; do not
treat it as the live decision record.
- When status changes, keep `PLANNING.md` §8, `roadmap.md`, and `risk-log.md`
in sync.
- Prefer updating existing docs over creating parallel overlapping docs.
## Communication with agents
- Read the relevant docs before proposing product/tech changes.
- Call out whether a suggestion is inside v1 scope or a future-phase idea.
- If requirements are ambiguous, ask before expanding scope.

16
CLAUDE.md Normal file
View File

@ -0,0 +1,16 @@
# Claude Code — hikikomori / 분신
Follow the project instructions in [@AGENTS.md](./AGENTS.md).
Quick context:
- This repo started as planning docs only; Phase 1 app-build has now begun.
Check `docs/roadmap.md` Phase 1 checklist before starting app-build work.
- Working product name: **분신** (tentative).
- Source of working decisions: `docs/decision-log.md` (status: 제안).
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
- Never weaken escalation, twin badge, peer veto, or undo.
Before changing product or technical direction, read `AGENTS.md` and the
relevant files under `docs/`.

46
README.md Normal file
View File

@ -0,0 +1,46 @@
# hikikomori / 분신 (가칭)
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신".
## 구조
| 경로 | 역할 |
|---|---|
| `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 |
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
| `backend/` | 초기 Python 프로토타입 (참고용) |
| `poc/tone-corpus/` | PoC 실험 스크립트 |
| `mobile/` | Flutter 클라이언트 (Android 우선) |
## 문서
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md)
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
## AI 에이전트 규칙
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
## 현재 단계
- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨
- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전
- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3)
## 로컬 실행 (요약)
```bash
# 터미널 1 — AI 서비스
cd ai-service && pip install -r requirements.txt
export GEMINI_API_KEY=... # or repo-root .env
uvicorn app.main:app --port 8001
# 터미널 2 — 코어 백엔드
cd core-backend && go run .
# 터미널 3 — Flutter (Android)
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
```

76
ai-service/README.md Normal file
View File

@ -0,0 +1,76 @@
# 분신 AI service (Python)
Phase 1 AI 서비스 (`docs/roadmap.md` Phase 1 §2.2). `poc/tone-corpus/`의 세 스크립트
(`generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`)를 그대로 승격한 내부 API —
`core-backend/`(Go)가 이 서비스를 내부망 HTTP로 호출한다 (`tech-design.md` §8).
`poc/tone-corpus/`는 그대로 둔다 — 거긴 코퍼스 실험/블라인드 평가용 PoC 도구로 계속 쓰고,
여기 코드가 실제로 서비스에 쓰이는 "승격된" 버전이다. 두 곳의 로직은 지금 동일하지만, 앞으로
갈라질 수 있다(예: 여기는 프로덕션 안정성 위주로만 바뀌고, PoC 쪽은 계속 실험적으로 바뀌는 식).
## 실행
```bash
pip install -r requirements.txt
export GEMINI_API_KEY=... # 또는 이 디렉토리에 .env 파일
uvicorn app.main:app --reload --port 8001
```
## 테스트
```bash
pip install -r requirements-dev.txt
pytest tests/ -v
```
`escalation_filter.py`(SELFTEST_CASES 승격 + 추가 케이스)·`retrieve_style.py`(overlap이 recency를
항상 이긴다는 것, 동점일 때만 recency가 tie-break한다는 것)·`generation.py`(escalate/no_key/ok 세
경로, Gemini 호출은 mock)·`main.py`(`/health`·`/escalate/check`·`/draft` 전부)를 `tests/`의 정식
pytest 스위트로 승격함 (`docs/roadmap.md` Phase 1 §2.5). 이전엔 ad-hoc TestClient 스크립트로만
확인했던 것들.
## API
### `POST /escalate/check`
```json
{ "text": "계좌번호 알려줄래?" }
```
응답: `{ "escalate": true, "reason": "금전" }`
`/draft`와 별개로 존재하는 독립 하드게이트 엔드포인트 — `core-backend`가 트윈(자동발송) 메시지를
저장하기 *직전에* 이걸 직접 호출해서, `/draft`를 거치지 않은 발송 경로도 전부 이 게이트를 통과하게
만든다 (`docs/roadmap.md` Phase 1 §2.4, `AGENTS.md` 안전 불변식). TestClient로 금전/약속/감정
케이스와 비대상 텍스트 전부 확인함.
### `POST /draft`
```json
{
"context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"],
"style_examples": ["ㅇㅇ 좋지", "나도 궁금하네ㅋㅋ"]
}
```
`style_examples`(직접 큐레이션) 또는 `history`(과거 발화 전체, 자동 검색 — `k`로 개수 조절) 중
**정확히 하나만** 넣는다. 둘 다 넣거나 둘 다 안 넣으면 422.
응답:
```json
{ "status": "ok" | "escalate" | "no_key", "text": "..." }
```
- `ok`: `text`는 생성된 답장 초안
- `escalate`: `text`는 에스컬레이션 사유 (금전/약속 확정/감정적으로 무거운 주제) — 이 경우
LLM은 호출되지 않는다 (`escalation_filter.py`가 하드 게이트)
- `no_key`: `GEMINI_API_KEY`가 없어서 실제 전송될 프롬프트만 `text`에 담아 반환
TestClient로 style_examples/history 두 경로, 에스컬레이션 케이스, 검증 오류(422) 전부 확인함.
## 아직 없는 것
- 온디바이스 말투 이력 저장 (이건 클라이언트/코어 백엔드 쪽 책임 — `tech-design.md` §2 참고)
- 사후 알림 + 되돌리기 UI/전체 흐름 (코어 백엔드는 이제 에스컬레이션 시 `escalation_logs`에 기록은
하지만, 사용자에게 사후 알림을 띄우고 되돌리는 클라이언트 UX는 아직 — Flutter 쪽 작업)

View File

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Rule-based escalation gate -- the first step of the autonomy engine in
`docs/tech-design.md` §3. Money, appointment confirmation, and emotionally
heavy content always escalate to the human, at every autonomy level, with
no exception (`AGENTS.md` absolute safety invariants).
This runs BEFORE any LLM call. `generation.py`'s own [ESCALATE]
instruction in its system prompt is a second line of defense for whatever
this misses, not a replacement for it -- a keyword miss must not be the
only thing standing between a user and an auto-sent money confirmation.
v1 is keyword/regex only, per tech-design.md §3: "100% 정확도를 목표하지
않는다 -- 애매하면 항상 에스컬레이션 쪽으로 fail-safe." Tune the pattern
lists against real false positive/negative rates once PoC data comes in.
Identical to poc/tone-corpus/escalation_filter.py -- promoted here
verbatim per roadmap.md Phase 1 §2.2 ("PoC 스크립트를 FastAPI로 승격").
"""
import re
from dataclasses import dataclass
MONEY_PATTERNS = [
r"\d[\d,]*\s*(원|만원|천원)",
r"(계좌|입금|송금|이체|환불|결제|대출|카드번호|계좌번호)",
]
APPOINTMENT_PATTERNS = [
r"(그럼|그러면).{0,10}(맞지|확정|콜)",
r"(약속|만나|보자).{0,10}(확정|잡자|정하자)",
r"\d{1,2}시.{0,10}(맞지|확정|괜찮|어때)",
]
EMOTIONAL_KEYWORDS = [
"힘들어", "힘들다", "슬퍼", "슬프다", "우울", "죽고싶", "죽고 싶",
"아파", "이별", "헤어졌", "헤어지자", "싸웠어", "화나", "짜증나", "속상",
]
_MONEY = [re.compile(p) for p in MONEY_PATTERNS]
_APPOINTMENT = [re.compile(p) for p in APPOINTMENT_PATTERNS]
@dataclass
class EscalationResult:
escalate: bool
reason: str = ""
def check(text):
for pat in _MONEY:
if pat.search(text):
return EscalationResult(True, "금전")
for pat in _APPOINTMENT:
if pat.search(text):
return EscalationResult(True, "약속 확정")
for kw in EMOTIONAL_KEYWORDS:
if kw in text:
return EscalationResult(True, "감정적으로 무거운 주제")
return EscalationResult(False)

View File

@ -0,0 +1,85 @@
"""Tone-matched reply drafting -- the core of `generate_draft.py`
(`poc/tone-corpus/`), promoted here as a FastAPI-callable library per
roadmap.md Phase 1 §2.2. This is the server-fallback path described in
`docs/tech-design.md` §2 ("온디바이스 부족시 서버 LLM 호출, 최소 컨텍스트만
전송") -- a real on-device model would replace the API call later, but the
prompt contract (exemplars + context in, one draft out) stays the same.
"""
import os
from pathlib import Path
from .escalation_filter import check as check_escalation
def load_dotenv_if_present():
"""Load KEY=VALUE pairs from the nearest .env (service root preferred)."""
if os.environ.get("GEMINI_API_KEY"):
return
here = Path(__file__).resolve()
candidates = [here.parent.parent / ".env", Path.cwd() / ".env"]
for env_path in candidates:
if not env_path.is_file():
continue
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("'").strip('"')
if key and key not in os.environ:
os.environ[key] = value
break
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
지켜야 :
- 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다.
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
def build_user_prompt(style_examples, context_lines):
examples = "\n".join(f"- {s}" for s in style_examples)
context = "\n".join(context_lines)
return f"""[말투 예시]\n{examples}\n\n[최근 대화]\n{context}\n\n위 대화의 마지막 메시지에 대한 답장 초안:"""
def last_incoming_text(context_lines):
"""The message needing a reply -- last line, minus its '상대: '/'나: ' prefix."""
if not context_lines:
return ""
last = context_lines[-1]
return last.split(": ", 1)[1] if ": " in last else last
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft.
"""
gate = check_escalation(last_incoming_text(context_lines))
if gate.escalate:
return "escalate", gate.reason
api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
return "no_key", build_user_prompt(style_examples, context_lines)
from google import genai # pip install google-genai
from google.genai import types
client = genai.Client(api_key=api_key)
resp = client.models.generate_content(
model=model,
contents=build_user_prompt(style_examples, context_lines),
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
)
return "ok", resp.text.strip()

74
ai-service/app/main.py Normal file
View File

@ -0,0 +1,74 @@
from contextlib import asynccontextmanager
from typing import List, Optional
from fastapi import FastAPI
from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples
@asynccontextmanager
async def lifespan(app: FastAPI):
load_dotenv_if_present()
yield
app = FastAPI(title="분신 AI service", lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ok"}
class DraftRequest(BaseModel):
context_lines: List[str]
style_examples: Optional[List[str]] = None
history: Optional[List[str]] = None
k: int = 6
model: str = "gemini-2.5-flash"
@model_validator(mode="after")
def check_exactly_one_style_source(self):
if bool(self.style_examples) == bool(self.history):
raise ValueError("provide exactly one of style_examples or history")
return self
class DraftResponse(BaseModel):
status: str
text: str
class EscalationCheckRequest(BaseModel):
text: str
class EscalationCheckResponse(BaseModel):
escalate: bool
reason: str = ""
@app.post("/escalate/check", response_model=EscalationCheckResponse)
def escalate_check(req: EscalationCheckRequest):
"""Standalone hard gate, decoupled from draft generation (AGENTS.md
absolute safety invariants). core-backend calls this directly before
persisting any twin-authored (auto-sent) message, so the gate applies
even to sends that never went through /draft."""
result = check_escalation(req.text)
return EscalationCheckResponse(escalate=result.escalate, reason=result.reason)
@app.post("/draft", response_model=DraftResponse)
def draft(req: DraftRequest):
if req.style_examples is not None:
style_examples = req.style_examples
else:
style_examples = retrieve_style_examples(
req.history, last_incoming_text(req.context_lines), k=req.k
)
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
return DraftResponse(status=status, text=text)

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Keyword/recency-based retrieval -- the search step of the personalization
layer in `docs/tech-design.md` §2-1: "지금부터 임베딩 인프라를 먼저 만들지
않는다" -- so this is plain token overlap + recency, not embeddings.
Identical to poc/tone-corpus/retrieve_style.py -- promoted here verbatim
per roadmap.md Phase 1 §2.2.
"""
import re
TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]+")
def tokenize(text):
return set(TOKEN_RE.findall(text))
def _jaccard(a, b):
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def retrieve(history_messages, query_text, k=6):
"""history_messages: oldest-first list of past messages by the person.
Ranked by keyword overlap with query_text first; recency only breaks
ties. A weighted-sum of the two was tried and dropped -- Korean short
messages rarely share more than one or two tokens (no particle
stripping here, so "핀란드" and "핀란드는" don't match each other),
so any nonzero recency weight ended up drowning out real overlap and
just returning the most recent messages regardless of topic."""
query_tokens = tokenize(query_text)
n = len(history_messages)
scored = []
for i, msg in enumerate(history_messages):
overlap = _jaccard(tokenize(msg), query_tokens)
recency = i / max(n - 1, 1)
scored.append(((overlap, recency), msg))
scored.sort(key=lambda pair: pair[0], reverse=True)
return [msg for _, msg in scored[:k]]

2
ai-service/pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
pythonpath = .

View File

@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0.0

View File

@ -0,0 +1,4 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.0.0
google-genai>=1.0.0

View File

View File

@ -0,0 +1,48 @@
"""Promotes escalation_filter.py's SELFTEST_CASES (poc/tone-corpus/escalation_filter.py,
identical logic here) into a real pytest suite -- roadmap.md Phase 1 §2.5."""
import pytest
from app.escalation_filter import check
SELFTEST_CASES = [
("계좌로 3만원만 보내줘", True, "금전"),
("그 카페 계좌번호 좀 알려줄래", True, "금전"),
("그럼 내일 3시 맞지?", True, "약속 확정"),
("약속 시간 확정하자, 언제가 좋아", True, "약속 확정"),
("나 요즘 너무 힘들어서 죽고싶다는 생각이 들어", True, "감정적으로 무거운 주제"),
("우리 어제 왜 그렇게 싸웠어", True, "감정적으로 무거운 주제"),
("오늘 저녁에 뭐 먹을래?", False, ""),
("크라비 여행 가보고 싶어", False, ""),
("고등학교 학점제가 뭔지 설명해줄 수 있어?", False, ""),
("이 영화 재밌었어? 나도 보고싶다", False, ""),
]
@pytest.mark.parametrize("text,expected_escalate,expected_reason", SELFTEST_CASES)
def test_selftest_cases(text, expected_escalate, expected_reason):
result = check(text)
assert result.escalate == expected_escalate
if expected_escalate:
assert result.reason == expected_reason
else:
assert result.reason == ""
def test_money_wins_over_no_match_when_both_patterns_could_apply():
# 금전 패턴이 먼저 검사되므로 금전+약속이 섞여도 이유는 금전으로 고정된다.
result = check("계좌번호 알려주면 내일 3시 맞지?")
assert result.escalate is True
assert result.reason == "금전"
def test_empty_text_never_escalates():
result = check("")
assert result.escalate is False
def test_emotional_keyword_substring_match_is_intentionally_broad():
# 키워드 포함 매칭이라 오탐이 있을 수 있다 -- tech-design.md §3의 "애매하면
# 항상 에스컬레이션 쪽으로 fail-safe" 원칙과 일치하는 의도된 동작.
result = check("나 미드 정주행하다가 화나는 장면 나와서 잠깐 멈췄어")
assert result.escalate is True
assert result.reason == "감정적으로 무거운 주제"

View File

@ -0,0 +1,75 @@
"""Promotes generate_draft.py's manual verification into pytest -- roadmap.md
Phase 1 §2.5. The Gemini call is mocked; this only asserts the three status
branches (escalate/no_key/ok) and the exact request shape sent to the model,
not real generation quality (that's blind_eval.py's job)."""
import sys
import types
from unittest.mock import MagicMock
from app.generation import build_user_prompt, draft_reply, last_incoming_text
def test_last_incoming_text_strips_speaker_prefix():
assert last_incoming_text(["나: ㅇㅇ", "상대: 오늘 뭐해?"]) == "오늘 뭐해?"
def test_last_incoming_text_empty_context():
assert last_incoming_text([]) == ""
def test_build_user_prompt_includes_examples_and_context():
prompt = build_user_prompt(["ㅋㅋ 그러네"], ["상대: 안녕"])
assert "ㅋㅋ 그러네" in prompt
assert "상대: 안녕" in prompt
def test_draft_reply_escalates_before_any_model_call():
# api_key is present but escalation must short-circuit before genai is
# even imported -- if this regresses, google.genai.Client below would
# need to exist/be reachable and this test would start hitting real
# import/network paths instead of returning early.
status, text = draft_reply(["ㅇㅇ"], ["상대: 계좌번호 좀 알려줘"], api_key="fake-key")
assert status == "escalate"
assert text == "금전"
def test_draft_reply_no_key_returns_prompt_without_calling_model(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = draft_reply(["ㅇㅇ"], ["상대: 오늘 저녁 뭐 먹을래?"], api_key=None)
assert status == "no_key"
assert "오늘 저녁 뭐 먹을래" in text
def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch):
# generation.py does `from google import genai` / `from google.genai import
# types` *inside* draft_reply, so we stub those modules in sys.modules
# before the call -- this also sidesteps google-genai's real dependency
# chain (google-auth -> cryptography), which isn't needed for a unit test
# and doesn't import cleanly in every sandbox.
fake_response = MagicMock()
fake_response.text = " ㅇㅇ 좋지 "
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
status, text = draft_reply(
["ㅇㅇ 좋지"], ["상대: 오늘 저녁 뭐 먹을래?"], model="gemini-2.5-flash", api_key="fake-key"
)
assert status == "ok"
assert text == "ㅇㅇ 좋지" # stripped
fake_genai.Client.assert_called_once_with(api_key="fake-key")
fake_client.models.generate_content.assert_called_once()
_, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash"
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]

View File

@ -0,0 +1,92 @@
"""Promotes the ad-hoc TestClient checks used to verify /draft and
/escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
def test_escalate_check_money():
resp = client.post("/escalate/check", json={"text": "계좌번호 알려줄래?"})
assert resp.status_code == 200
assert resp.json() == {"escalate": True, "reason": "금전"}
def test_escalate_check_benign():
resp = client.post("/escalate/check", json={"text": "오늘 저녁에 뭐 먹을래?"})
assert resp.status_code == 200
assert resp.json() == {"escalate": False, "reason": ""}
def test_escalate_check_requires_text_field():
resp = client.post("/escalate/check", json={})
assert resp.status_code == 422
def test_draft_with_style_examples_no_key(monkeypatch):
# No GEMINI_API_KEY in this test environment -- assert the deterministic
# no_key path rather than hitting the real Gemini API.
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"],
"style_examples": ["ㅇㅇ 좋지", "나도 궁금하네ㅋㅋ"],
},
)
assert resp.status_code == 200
assert resp.json()["status"] == "no_key"
def test_draft_escalates_without_calling_style_source_logic():
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 계좌번호 좀 알려줘"],
"style_examples": ["ㅇㅇ 알겠어"],
},
)
assert resp.status_code == 200
assert resp.json() == {"status": "escalate", "text": "금전"}
def test_draft_with_history_uses_retrieval(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 오늘 저녁에 뭐 먹을래?"],
"history": ["어제 저녁엔 라면 먹었어", "핀란드는 교육이 좋대"],
"k": 1,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "no_key"
# 검색된 스타일 예시가 프롬프트에 반영됐는지는 no_key 응답의 text(프롬프트
# 원문)로 확인 -- retrieve()가 실제로 호출·반영됐다는 증거.
assert "어제 저녁엔 라면 먹었어" in body["text"] or "핀란드는 교육이 좋대" in body["text"]
def test_draft_rejects_both_style_sources():
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 안녕"],
"style_examples": ["ㅇㅇ"],
"history": ["ㅇㅇ"],
},
)
assert resp.status_code == 422
def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422

View File

@ -0,0 +1,50 @@
"""Promotes retrieve_style.py's manual verification into pytest -- roadmap.md
Phase 1 §2.5. Covers the recency-vs-overlap bug fixed during PoC work: overlap
must always outrank recency, recency only breaks ties (see retrieve_style.py's
docstring)."""
from app.retrieve_style import _jaccard, retrieve, tokenize
def test_tokenize_extracts_hangul_and_alnum_only():
assert tokenize("핀란드는 교육이 좋대!!") == {"핀란드는", "교육이", "좋대"}
def test_jaccard_empty_sets_score_zero():
assert _jaccard(set(), {"a"}) == 0.0
assert _jaccard({"a"}, set()) == 0.0
def test_jaccard_identical_sets_score_one():
assert _jaccard({"a", "b"}, {"a", "b"}) == 1.0
def test_keyword_overlap_outranks_recency():
# 핀란드 관련 예시가 훨씬 과거에 있어도, 방금 온 무관한 최신 메시지들보다
# 우선 검색돼야 한다 -- 가중합으로 점수를 매기면 recency가 이를 뒤집는
# 버그가 있었음 (poc 작업 중 발견/수정).
history = [
"핀란드는 교육이 진짜 잘 되어있대",
"오늘 저녁 뭐 먹지",
"나 요즘 잠을 못 자",
"어제 넷플릭스 뭐 봤어",
]
result = retrieve(history, "핀란드는 교육이 좋대", k=1)
assert result == ["핀란드는 교육이 진짜 잘 되어있대"]
def test_recency_breaks_ties_among_equal_overlap():
# Both candidates share exactly one token with the query and have the
# same union size, so their Jaccard overlap ties at 0.25 -- only then
# should recency (the later message) win.
query = "사과 바나나 포도"
older, newer = "사과 딸기", "바나나 수박"
assert _jaccard(tokenize(older), tokenize(query)) == _jaccard(tokenize(newer), tokenize(query))
result = retrieve([older, newer], query, k=1)
assert result == [newer]
def test_k_limits_result_count():
history = [f"메시지 {i}" for i in range(10)]
result = retrieve(history, "메시지", k=3)
assert len(result) == 3

41
backend/README.md Normal file
View File

@ -0,0 +1,41 @@
# 분신 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다
(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로,
인증·메시지 릴레이·DB 스키마가 실제로 동작하는 걸 검증하는 용도로는 여전히 유효하다 —
**API 설계·DB 스키마 참고용으로 남겨두고, Go 코어 백엔드를 새로 만들 때 이 동작을 그대로 재현한다.**
AI 서비스(2.2)는 그대로 Python으로 간다 — 그건 이 디렉토리가 아니라 별도 서비스로 만들 것.
## 실행
```bash
pip install -r requirements.txt
uvicorn app.main:app --reload
```
기본은 `sqlite:///./dev.db`로 뜬다. 프로덕션 DB를 쓰려면:
```bash
export DATABASE_URL=postgresql+psycopg2://user:pass@host/dbname
```
## 지금 있는 것 (2.1 백엔드 인프라 뼈대)
- `GET /health` — 헬스체크
- `POST /auth/signup` — 초대 코드 기반 가입 (중복 코드는 409)
- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트
- `WS /ws/conversations/{id}` — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저)
- DB 모델 (`app/models.py`): `users`, `contacts`, `conversations`,
`conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`,
`escalation_logs``roadmap.md` Phase 1 §2.1 스키마 그대로
signup/message-send/404/WebSocket 브로드캐스트까지 `TestClient`로 실제 실행해서 확인함
(테스트 스크립트는 커밋 안 함 — 필요하면 정식 `tests/`로 다시 만들 것).
## 아직 없는 것 (다음 워크스트림)
- 2.2 AI 파이프라인 연동 — `poc/tone-corpus/generate_draft.py` 등을 여기 API로 이식
- 푸시 알림 연동
- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음)
- 프로덕션 마이그레이션 도구 (지금은 `Base.metadata.create_all`로 스타트업 시 테이블 생성 —
Alembic 같은 마이그레이션은 스키마가 안정되면 도입)

0
backend/app/__init__.py Normal file
View File

8
backend/app/config.py Normal file
View File

@ -0,0 +1,8 @@
import os
# Production: postgresql+psycopg2://user:pass@host/dbname (tech-design.md §8).
# Defaults to a local SQLite file so the app runs without a Postgres instance
# during development/testing.
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./dev.db")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")

17
backend/app/db.py Normal file
View File

@ -0,0 +1,17 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from .config import DATABASE_URL
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

103
backend/app/main.py Normal file
View File

@ -0,0 +1,103 @@
from typing import Dict, List
from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy.orm import Session
from . import models
from .db import Base, engine, get_db
app = FastAPI(title="분신 backend")
@app.on_event("startup")
def create_tables():
Base.metadata.create_all(bind=engine)
@app.get("/health")
def health():
return {"status": "ok"}
class SignupRequest(BaseModel):
invite_code: str
display_name: str
@app.post("/auth/signup")
def signup(req: SignupRequest, db: Session = Depends(get_db)):
existing = db.query(models.User).filter_by(invite_code=req.invite_code).first()
if existing:
raise HTTPException(status_code=409, detail="invite_code already used")
user = models.User(invite_code=req.invite_code, display_name=req.display_name)
db.add(user)
db.commit()
db.refresh(user)
db.add(models.TwinSettings(user_id=user.id))
db.commit()
return {"id": user.id, "display_name": user.display_name}
class SendMessageRequest(BaseModel):
sender_id: int
text: str
sender_mode: models.SenderMode = models.SenderMode.HUMAN
@app.post("/conversations/{conversation_id}/messages")
async def send_message(conversation_id: int, req: SendMessageRequest, db: Session = Depends(get_db)):
conversation = db.query(models.Conversation).get(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="conversation not found")
message = models.Message(
conversation_id=conversation_id,
sender_id=req.sender_id,
sender_mode=req.sender_mode,
text=req.text,
)
db.add(message)
db.commit()
db.refresh(message)
await relay.broadcast(conversation_id, {
"id": message.id,
"sender_id": message.sender_id,
"sender_mode": message.sender_mode.value,
"text": message.text,
})
return {"id": message.id}
class ConnectionManager:
"""In-memory WebSocket fan-out per conversation. Fine for a small closed
beta (roadmap.md Phase 1); revisit if the relay needs to scale past one
process."""
def __init__(self):
self.connections: Dict[int, List[WebSocket]] = {}
async def connect(self, conversation_id: int, websocket: WebSocket):
await websocket.accept()
self.connections.setdefault(conversation_id, []).append(websocket)
def disconnect(self, conversation_id: int, websocket: WebSocket):
conns = self.connections.get(conversation_id, [])
if websocket in conns:
conns.remove(websocket)
async def broadcast(self, conversation_id: int, payload: dict):
for ws in self.connections.get(conversation_id, []):
await ws.send_json(payload)
relay = ConnectionManager()
@app.websocket("/ws/conversations/{conversation_id}")
async def conversation_socket(websocket: WebSocket, conversation_id: int):
await relay.connect(conversation_id, websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
relay.disconnect(conversation_id, websocket)

128
backend/app/models.py Normal file
View File

@ -0,0 +1,128 @@
import enum
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.orm import relationship
from .db import Base
class AutonomyLevel(str, enum.Enum):
L0 = "L0"
L1 = "L1"
L2 = "L2"
class SenderMode(str, enum.Enum):
HUMAN = "human"
TWIN = "twin"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
invite_code = Column(String, unique=True, nullable=False, index=True)
display_name = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
twin_settings = relationship("TwinSettings", back_populates="user", uselist=False)
class Contact(Base):
"""A relationship between the owner and a counterpart -- carries
per-peer state like the veto flag (tech-design.md §4:
twin_disabled_by_peer), independent of any one conversation."""
__tablename__ = "contacts"
id = Column(Integer, primary_key=True)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
contact_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
display_name = Column(String, nullable=False)
relationship_note = Column(String, nullable=True)
twin_disabled_by_peer = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class Conversation(Base):
__tablename__ = "conversations"
id = Column(Integer, primary_key=True)
is_group = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
participants = relationship("ConversationParticipant", back_populates="conversation")
messages = relationship("Message", back_populates="conversation")
class ConversationParticipant(Base):
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
conversation = relationship("Conversation", back_populates="participants")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
sender_mode = Column(Enum(SenderMode), nullable=False, default=SenderMode.HUMAN)
text = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
conversation = relationship("Conversation", back_populates="messages")
class TwinSettings(Base):
__tablename__ = "twin_settings"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
autonomy_level = Column(Enum(AutonomyLevel), nullable=False, default=AutonomyLevel.L0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="twin_settings")
class WhitelistRule(Base):
"""L2 auto-send whitelist -- a (contact, topic) pair the owner has
approved for unattended replies. contact_id null = applies to any
counterpart (PRD.md §3.1)."""
__tablename__ = "whitelist_rules"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
topic_keyword = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class EscalationLog(Base):
"""One row per escalation_filter.py trigger -- the post-hoc notification
+ undo trail required by AGENTS.md's absolute safety invariants."""
__tablename__ = "escalation_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
reason = Column(String, nullable=False)
message_snippet = Column(String, nullable=False)
resolved = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

6
backend/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0
websockets>=13.0
pydantic>=2.0.0

126
core-backend/README.md Normal file
View File

@ -0,0 +1,126 @@
# 분신 core-backend (Go)
Phase 1 코어 백엔드 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 `docs/tech-design.md` §8 참고 —
Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개발). `../backend/`(Python
프로토타입)의 동작을 그대로 재현한 것이다 — API·DB 스키마는 거기서 이미 검증된 것과 동일하다.
## 실행
```bash
go mod download
go run .
```
기본은 `sqlite:./dev.db`로 뜬다. 프로덕션 DB를 쓰려면:
```bash
export DATABASE_URL="postgres://user:pass@host/dbname"
```
AI 서비스(`../ai-service/`)를 호출하려면:
```bash
export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
```
## 테스트
```bash
go test ./... -v
```
`main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket
브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성
플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/
거부권(peer veto, 다른 모든 조건보다 우선)/화이트리스트 규칙 CRUD/메시지 되돌리기(twin 전용, WebSocket
브로드캐스트 포함)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 돌려서 확인한다
(`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들).
## 지금 있는 것
**2.1 코어 백엔드**
- `GET /health` — 헬스체크
- `POST /auth/signup` — 초대 코드 기반 가입. 미리 발급된(`POST /invites`) 미사용 코드가 아니면
400, 이미 쓴 코드면 409 (아래 2.6)
- `POST /conversations/{id}/messages` — 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트.
`sender_mode: "twin"`인 요청은 저장 전에 반드시 거부권·에스컬레이션 하드게이트를 통과해야 한다
(아래 2.4)
- `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다.
이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이
전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음)
- `POST /messages/{id}/retract` — 되돌리기(one-tap undo, PRD.md §3.1): **트윈이 자동발송한
메시지만** 대상. 사람이 직접 쓴 메시지는 400. 이미 되돌린 메시지를 다시 호출하면 409. 성공하면
`Message.Retracted`를 켜고 같은 대화방의 WebSocket 연결에 `{"type": "retraction", "id": ...}`
브로드캐스트한다 (아래 참고: 일반 메시지 브로드캐스트도 이제 `"type": "message"`를 포함해서
클라이언트가 두 이벤트를 구분할 수 있게 함)
- `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저)
- `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트·
연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴
초대 코드는 "사용됨" 상태는 유지한 채 유저 참조만 지운다 (`tech-design.md` §5 "사용자가
언제든 초기화 가능")
- DB 모델 (`models.go`): `users`, `invite_codes`, `contacts`, `conversations`,
`conversation_participants`, `messages`, `twin_settings`, `whitelist_rules`, `escalation_logs`
`../backend/app/models.py`와 동일한 스키마(+ `invite_codes`는 여기서 새로 추가)
**2.2 AI 서비스 연동**
- `POST /conversations/{id}/draft``ai-service/``POST /draft`를 호출해 초안을 프록시 반환
(`style_examples`/`history` 중 하나 필수, 없으면 400)
**2.4 안전장치 통합**
- 트윈 발송 시 체크 순서: **거부권 → 에스컬레이션 → 자율성 레벨**. 앞 단계에서 막히면 뒤 단계는
아예 확인하지 않는다 — 순서가 바뀌면 안 되는 이유는 거부권/에스컬레이션이 레벨·화이트리스트·
승인 여부보다 항상 우선해야 하기 때문 (테스트로 확인함)
- 거부권: `conversation.TwinDisabledByPeer`가 켜져 있으면 그 자리에서 403 — AI 서비스 호출조차
하지 않는다. 사람이 직접 보내는 메시지는 영향받지 않는다
- `AIServiceClient.checkEscalation` (`aiservice.go`) — `ai-service``POST /escalate/check` 호출.
`/conversations/{id}/messages``sender_mode: "twin"`을 받을 때마다 이걸 호출해서, 어떤
경로로 왔든(그리고 `/draft`를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는
하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고 `escalation_logs`
기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를
확인 못 했다는 불확실성을 자동발송 허용 쪽으로 풀지 않는다.
- 사람이 직접 보내는 메시지(`sender_mode` 기본값 `human`)는 이 게이트를 타지 않는다 — 본인이 직접
쓴 말을 막을 이유가 없다.
**2.2/2.5 자율성 엔진(L0~L2) 최소 오케스트레이션** (PRD.md §2.1/§2.2, roadmap.md §2.5 QA 작업 중
필요해져서 구현)
- `PATCH /users/{id}/twin-settings``{"autonomy_level": "L0"|"L1"|"L2"}`로 전역 자율성 레벨 변경
(가입 시 기본값은 L0, PRD.md §2.1)
- `POST`/`GET /users/{id}/whitelist-rules`, `DELETE /users/{id}/whitelist-rules/{ruleId}`
화이트리스트 규칙 CRUD (`{"topic_keyword": "...", "contact_id": null|uint}`). `contact_id`
저장은 되지만 아직 매칭에는 안 쓰인다 — 바로 아래 참고
- 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: **L0**은 항상 차단(403, 초안만 가능), **L1**은
요청에 `approved: true`가 없으면 차단(403), **L2**는 `whitelist_rules`에 매칭되는 주제면
`approved` 없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요
- 화이트리스트 매칭(`whitelistMatches`)은 v1 최소 구현 — 유저의 모든 `WhitelistRule.TopicKeyword`
메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 CRUD로
저장은 되지만 매칭 로직에서는 아직 무시함 — 대화방↔연락처 연결이 아직 모델링되어 있지 않아서
(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요)
- 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved:
true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함)
**2.6 베타 배포 준비**
- `POST /invites` — 새 초대 코드 발급(무작위 10자 hex). 아직 발급자 인증이 없어 누구나 호출
가능 — 인증/세션이 생기기 전까지는 서버 콘솔·내부 도구에서만 호출한다고 가정
- `GET /admin/metrics``users_total`·`messages_human_total`·`messages_twin_total`·
`escalations_total`·`escalations_by_reason`·`conversations_total`·`conversations_vetoed`·
`peer_veto_rate`·`invites_minted`·`invites_used`. 지금 스키마로 정직하게 계산 가능한 것만 —
`peer_veto_rate`는 vision.md "분신 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님.
생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
## 아직 없는 것 (다음 워크스트림)
- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은
아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요)
- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는
건 Flutter 쪽)
- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던
목록"을 조회하는 API는 아직 없음 — 필요해지면 추가)
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어
로컬 환경에서 진행)
- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음)
- `/invites`·`/admin/metrics` 접근 제어 (지금은 인증이 없어 누구나 호출 가능 — 아래 인증 항목과 같이 해결)
- 푸시 알림 연동
- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음)
- 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입)
- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우)

91
core-backend/aiservice.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
// AIServiceClient calls the Python AI service's POST /draft (ai-service/,
// tech-design.md §8's "Go 코어 → Python AI 서비스, 내부망 HTTP").
type AIServiceClient struct {
BaseURL string
HTTP *http.Client
}
func newAIServiceClient() *AIServiceClient {
return &AIServiceClient{BaseURL: aiServiceURL(), HTTP: http.DefaultClient}
}
type draftRequest struct {
ContextLines []string `json:"context_lines"`
StyleExamples []string `json:"style_examples,omitempty"`
History []string `json:"history,omitempty"`
K int `json:"k,omitempty"`
}
type draftResponse struct {
Status string `json:"status"`
Text string `json:"text"`
}
func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/draft", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out draftResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
type escalationCheckRequest struct {
Text string `json:"text"`
}
type escalationCheckResponse struct {
Escalate bool `json:"escalate"`
Reason string `json:"reason"`
}
// checkEscalation calls ai-service's POST /escalate/check -- the hard gate
// that any twin-authored (auto-sent) message must pass, independent of
// whether a draft was generated through this client. Callers must fail
// closed (treat an error here as "escalate") per AGENTS.md's fail-safe
// invariant: uncertainty must never resolve to an unattended send.
func (c *AIServiceClient) checkEscalation(text string) (*escalationCheckResponse, error) {
body, err := json.Marshal(escalationCheckRequest{Text: text})
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/escalate/check", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out escalationCheckResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}

20
core-backend/config.go Normal file
View File

@ -0,0 +1,20 @@
package main
import "os"
// Production: postgresql connection string via DATABASE_URL (tech-design.md §8).
// Defaults to a local SQLite file so the service runs without Postgres during
// development/testing.
func databaseURL() string {
if v := os.Getenv("DATABASE_URL"); v != "" {
return v
}
return "sqlite:./dev.db"
}
func aiServiceURL() string {
if v := os.Getenv("AI_SERVICE_URL"); v != "" {
return v
}
return "http://localhost:8001"
}

32
core-backend/db.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"log"
"strings"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openDB() *gorm.DB {
url := databaseURL()
var dialector gorm.Dialector
if strings.HasPrefix(url, "sqlite:") {
dialector = sqlite.Open(strings.TrimPrefix(url, "sqlite:"))
} else {
dialector = postgres.Open(url)
}
db, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
if err := db.AutoMigrate(allModels...); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
return db
}

48
core-backend/go.mod Normal file
View File

@ -0,0 +1,48 @@
module hikikomori/core-backend
go 1.25.0
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.2 // indirect
)

103
core-backend/go.sum Normal file
View File

@ -0,0 +1,103 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

593
core-backend/main.go Normal file
View File

@ -0,0 +1,593 @@
package main
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type signupRequest struct {
InviteCode string `json:"invite_code" binding:"required"`
DisplayName string `json:"display_name" binding:"required"`
}
type sendMessageRequest struct {
SenderID uint `json:"sender_id" binding:"required"`
Text string `json:"text" binding:"required"`
SenderMode SenderMode `json:"sender_mode"`
// Approved represents the human tapping "승인" on an L1 draft, or on an
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
// human-authored messages.
Approved bool `json:"approved"`
}
type updateTwinSettingsRequest struct {
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
}
type createWhitelistRuleRequest struct {
ContactID *uint `json:"contact_id"`
TopicKeyword string `json:"topic_keyword" binding:"required"`
}
type draftMessageRequest struct {
ContextLines []string `json:"context_lines" binding:"required"`
StyleExamples []string `json:"style_examples"`
History []string `json:"history"`
K int `json:"k"`
}
func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gin.Engine {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
r.POST("/invites", func(c *gin.Context) {
code, err := generateInviteCode()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
invite := InviteCode{Code: code}
if err := db.Create(&invite).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": invite.Code})
})
r.GET("/admin/metrics", func(c *gin.Context) {
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from
// the current schema. Draft-generation latency and AI-service error
// rate need a request-timing/logging layer that doesn't exist yet --
// not fabricated here, left for that future work.
var usersTotal, humanMessages, twinMessages, escalationsTotal int64
var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64
db.Model(&User{}).Count(&usersTotal)
db.Model(&Message{}).Where("sender_mode = ?", SenderHuman).Count(&humanMessages)
db.Model(&Message{}).Where("sender_mode = ?", SenderTwin).Count(&twinMessages)
db.Model(&EscalationLog{}).Count(&escalationsTotal)
db.Model(&Conversation{}).Count(&conversationsTotal)
db.Model(&Conversation{}).Where("twin_disabled_by_peer = ?", true).Count(&conversationsVetoed)
db.Model(&InviteCode{}).Count(&invitesMinted)
db.Model(&InviteCode{}).Where("used_at IS NOT NULL").Count(&invitesUsed)
type reasonCount struct {
Reason string
Count int64
}
var reasonCounts []reasonCount
db.Model(&EscalationLog{}).Select("reason, count(*) as count").Group("reason").Scan(&reasonCounts)
escalationsByReason := map[string]int64{}
for _, rc := range reasonCounts {
escalationsByReason[rc.Reason] = rc.Count
}
var peerVetoRate float64
if conversationsTotal > 0 {
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
}
c.JSON(http.StatusOK, gin.H{
"users_total": usersTotal,
"messages_human_total": humanMessages,
"messages_twin_total": twinMessages,
"escalations_total": escalationsTotal,
"escalations_by_reason": escalationsByReason,
"conversations_total": conversationsTotal,
"conversations_vetoed": conversationsVetoed,
// Approximates vision.md's "분신 거부율" metric at conversation
// granularity (vetoed conversations / all conversations) --
// vision.md doesn't pin down the exact denominator, so treat
// this as a first approximation, not the final definition.
"peer_veto_rate": peerVetoRate,
"invites_minted": invitesMinted,
"invites_used": invitesUsed,
})
})
r.POST("/auth/signup", func(c *gin.Context) {
var req signupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
// 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용
// 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전
// 방식은 "초대 기반"이 아니었음.
var invite InviteCode
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
return
}
if invite.UsedAt != nil {
c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"})
return
}
user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
now := time.Now()
invite.UsedAt = &now
invite.UsedByUserID = &user.ID
db.Save(&invite)
c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName})
})
r.POST("/conversations/:id/messages", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
var conversation Conversation
if err := db.First(&conversation, convID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"})
return
}
var req sendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.SenderMode == "" {
req.SenderMode = SenderHuman
}
// Hard gate: a twin-authored send is an unattended action, so it
// must clear escalation_filter regardless of how it got here --
// this is the one chokepoint every twin auto-send passes through,
// so no client or upstream path can bypass it (AGENTS.md absolute
// safety invariants). Human-authored messages are the human's own
// words and are never gated. On any doubt (AI service unreachable
// or erroring) we fail closed and block the send. Peer veto is
// checked first (it's a total kill switch for this conversation,
// independent of content), then escalation, then autonomy level --
// none of the later checks can override an earlier block.
if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer {
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return
}
result, err := ai.checkEscalation(req.Text)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return
}
if result.Escalate {
db.Create(&EscalationLog{
UserID: req.SenderID,
ConversationID: convID,
Reason: result.Reason,
MessageSnippet: req.Text,
})
c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason})
return
}
// Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing
// settings fail closed to L0, the documented default.
level := AutonomyL0
var settings TwinSettings
if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil {
level = settings.AutonomyLevel
}
switch level {
case AutonomyL0:
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return
case AutonomyL1:
if !req.Approved {
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
return
}
case AutonomyL2:
if !req.Approved && !whitelistMatches(db, req.SenderID, req.Text) {
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
return
}
default:
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
return
}
}
message := Message{
ConversationID: convID,
SenderID: req.SenderID,
SenderMode: req.SenderMode,
Text: req.Text,
}
if err := db.Create(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
relay.broadcast(convID, gin.H{
"type": "message",
"id": message.ID,
"sender_id": message.SenderID,
"sender_mode": message.SenderMode,
"text": message.Text,
})
c.JSON(http.StatusOK, gin.H{"id": message.ID})
})
r.POST("/messages/:id/retract", func(c *gin.Context) {
msgID, ok := parseUintParam(c, "id")
if !ok {
return
}
var message Message
if err := db.First(&message, msgID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"})
return
}
// One-tap undo (PRD.md §3.1, AGENTS.md "every automatic action needs
// post-hoc notification + one-tap undo") applies to unattended
// twin auto-sends -- a human retracting their own words is a
// different, unrelated feature this endpoint doesn't cover.
if message.SenderMode != SenderTwin {
c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored (auto-sent) messages can be retracted"})
return
}
if message.Retracted {
c.JSON(http.StatusConflict, gin.H{"detail": "message already retracted"})
return
}
message.Retracted = true
if err := db.Save(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
relay.broadcast(message.ConversationID, gin.H{
"type": "retraction",
"id": message.ID,
})
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
})
r.POST("/conversations/:id/draft", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
var conversation Conversation
if err := db.First(&conversation, convID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"})
return
}
var req draftMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if len(req.StyleExamples) == 0 && len(req.History) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "provide style_examples or history"})
return
}
// EscalationLog persistence (sender's post-hoc notification + undo
// trail) is a separate checklist item -- roadmap.md Phase 1 §2.2
// "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies
// to the AI service for now.
result, err := ai.requestDraft(draftRequest{
ContextLines: req.ContextLines,
StyleExamples: req.StyleExamples,
History: req.History,
K: req.K,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text})
})
r.POST("/conversations/:id/veto", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
var conversation Conversation
if err := db.First(&conversation, convID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"})
return
}
// Peer veto (PRD.md §3.1, tech-design.md §4, AGENTS.md absolute
// safety invariants): the counterpart asked to talk to the human
// only. One-way for v1 -- no "un-veto" endpoint, matching the
// PRD's "즉시 중단" wording; nothing in scope calls for reversing it.
conversation.TwinDisabledByPeer = true
if err := db.Save(&conversation).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true})
})
r.PATCH("/users/:id/twin-settings", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var req updateTwinSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.AutonomyLevel != AutonomyL0 && req.AutonomyLevel != AutonomyL1 && req.AutonomyLevel != AutonomyL2 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
var settings TwinSettings
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "twin settings not found for user"})
return
}
settings.AutonomyLevel = req.AutonomyLevel
if err := db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
})
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
var req createWhitelistRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
rule := WhitelistRule{UserID: userID, ContactID: req.ContactID, TopicKeyword: req.TopicKeyword}
if err := db.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": rule.ID,
"user_id": rule.UserID,
"contact_id": rule.ContactID,
"topic_keyword": rule.TopicKeyword,
})
})
r.GET("/users/:id/whitelist-rules", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
var rules []WhitelistRule
db.Where("user_id = ?", userID).Order("id").Find(&rules)
out := make([]gin.H, 0, len(rules))
for _, rule := range rules {
out = append(out, gin.H{
"id": rule.ID,
"contact_id": rule.ContactID,
"topic_keyword": rule.TopicKeyword,
})
}
c.JSON(http.StatusOK, gin.H{"whitelist_rules": out})
})
r.DELETE("/users/:id/whitelist-rules/:ruleId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
ruleID, ok := parseUintParam(c, "ruleId")
if !ok {
return
}
var rule WhitelistRule
if err := db.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "whitelist rule not found"})
return
}
db.Delete(&rule)
c.JSON(http.StatusOK, gin.H{"deleted": true})
})
r.DELETE("/users/:id", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
// Right-to-erasure ("사용자가 언제든 초기화 가능", tech-design.md §5):
// wipes every row this user's ID appears on. Real chat history lives
// on-device first (tech-design.md §2/§5) -- the server only ever held
// a minimal relay copy, so deleting it here is not a partial erasure.
var messagesDeleted, escalationLogsDeleted int64
err := db.Transaction(func(tx *gorm.DB) error {
if res := tx.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil {
return res.Error
}
if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil {
return res.Error
}
if res := tx.Where("user_id = ?", userID).Delete(&WhitelistRule{}); res.Error != nil {
return res.Error
}
if res := tx.Where("owner_user_id = ?", userID).Delete(&Contact{}); res.Error != nil {
return res.Error
}
if res := tx.Where("user_id = ?", userID).Delete(&ConversationParticipant{}); res.Error != nil {
return res.Error
}
res := tx.Where("sender_id = ?", userID).Delete(&Message{})
if res.Error != nil {
return res.Error
}
messagesDeleted = res.RowsAffected
res = tx.Where("user_id = ?", userID).Delete(&EscalationLog{})
if res.Error != nil {
return res.Error
}
escalationLogsDeleted = res.RowsAffected
return tx.Delete(&user).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"deleted_user_id": userID,
"messages_deleted": messagesDeleted,
"escalation_logs_deleted": escalationLogsDeleted,
})
})
r.GET("/ws/conversations/:id", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
relay.add(convID, conn)
defer relay.remove(convID, conn)
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
})
return r
}
// whitelistMatches is a v1-minimal check: any of the user's WhitelistRule
// keywords appearing as a substring of the message text counts as a match.
// It intentionally ignores WhitelistRule.ContactID (per-counterpart
// whitelisting) because conversations aren't yet linked to a Contact row --
// that link needs its own design pass once the client's contact model
// exists, so this only supports the "any counterpart" case for now.
func whitelistMatches(db *gorm.DB, userID uint, text string) bool {
var rules []WhitelistRule
db.Where("user_id = ?", userID).Find(&rules)
for _, rule := range rules {
if strings.Contains(text, rule.TopicKeyword) {
return true
}
}
return false
}
func generateInviteCode() (string, error) {
buf := make([]byte, 5)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func parseUintParam(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": name + " must be a positive integer"})
return 0, false
}
return uint(id), true
}
func main() {
db := openDB()
relay := newConnectionManager()
ai := newAIServiceClient()
r := setupRouter(db, relay, ai)
r.Run(":8080")
}

842
core-backend/main_test.go Normal file
View File

@ -0,0 +1,842 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// mockAIService stands in for ai-service/ during tests -- it echoes back
// a canned response so core-backend's HTTP client code (not the Python
// service itself) is what's under test here.
func mockAIService(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/draft":
var req draftRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
case "/escalate/check":
// Mirrors escalation_filter.py's rule set closely enough to
// exercise core-backend's gating logic; the rules themselves
// are verified against ai-service/app/escalation_filter.py directly.
var req escalationCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
out := escalationCheckResponse{}
switch {
case strings.Contains(req.Text, "계좌") || strings.Contains(req.Text, "송금"):
out = escalationCheckResponse{Escalate: true, Reason: "금전"}
case strings.Contains(req.Text, "힘들어"):
out = escalationCheckResponse{Escalate: true, Reason: "감정적으로 무거운 주제"}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return server
}
func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
t.Helper()
dbPath := t.TempDir() + "/test.db"
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(allModels...); err != nil {
t.Fatalf("migrate: %v", err)
}
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
gin.SetMode(gin.TestMode)
router := setupRouter(db, newConnectionManager(), ai)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
return server, db
}
func postJSON(t *testing.T, url string, body interface{}) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(b))
if err != nil {
t.Fatalf("post %s: %v", url, err)
}
return resp
}
func mintInvite(t *testing.T, serverURL string) string {
t.Helper()
resp := postJSON(t, serverURL+"/invites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return out["code"].(string)
}
func mustSignup(t *testing.T, serverURL, displayName string) uint {
t.Helper()
resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 signing up %s, got %d", displayName, resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return uint(out["id"].(float64))
}
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) {
t.Helper()
body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level})
req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("set autonomy level: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 setting autonomy level, got %d", resp.StatusCode)
}
}
func TestHealth(t *testing.T) {
server, _ := setupTestServer(t)
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestSignupAndDuplicateRejected(t *testing.T) {
server, _ := setupTestServer(t)
code := mintInvite(t, server.URL)
resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "지우"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["display_name"] != "지우" {
t.Fatalf("unexpected body: %v", out)
}
dup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "dup"})
if dup.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 for a reused invite code, got %d", dup.StatusCode)
}
}
func TestSignupRejectsUnknownInviteCode(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: "never-minted", DisplayName: "누구"})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for an invite code nobody minted, got %d", resp.StatusCode)
}
}
func TestMintInviteReturnsUniqueCodes(t *testing.T) {
server, _ := setupTestServer(t)
a := mintInvite(t, server.URL)
b := mintInvite(t, server.URL)
if a == b {
t.Fatalf("expected distinct invite codes, got %q twice", a)
}
}
func TestSendMessageToMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/messages", sendMessageRequest{SenderID: 1, Text: "hi"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "정우")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
strconv.FormatUint(uint64(conv.ID), 10)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer ws.Close()
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "안녕하세요",
SenderMode: SenderTwin,
Approved: true,
})
if sendResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode)
}
var received map[string]interface{}
if err := ws.ReadJSON(&received); err != nil {
t.Fatalf("read ws message: %v", err)
}
if received["text"] != "안녕하세요" || received["sender_mode"] != "twin" {
t.Fatalf("unexpected ws payload: %v", received)
}
}
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "민지")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
strconv.FormatUint(uint64(conv.ID), 10)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer ws.Close()
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "계좌번호 알려줄게",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for escalated twin send, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("escalated twin message must not be persisted, found %d rows", count)
}
var logs []EscalationLog
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 || logs[0].Reason != "금전" {
t.Fatalf("expected one EscalationLog row with reason 금전, got %+v", logs)
}
ws.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if _, _, err := ws.ReadMessage(); err == nil {
t.Fatal("expected no broadcast for a blocked escalated message")
}
}
// The four tests below cover PRD.md §2.1/§2.2's L0->L1->L2 autonomy flow:
// L0 (기본값) never auto-sends, L1 requires explicit approval, L2 auto-sends
// only for whitelisted topics and otherwise behaves like L1.
func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "하늘")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// New users default to L0 (PRD.md §2.1) -- a non-escalating twin send
// must still be blocked, since L0 means no auto-send at all.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "ㅇㅇ 알겠어",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 blocking twin auto-send at L0, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("L0 must never persist a twin auto-send, found %d rows", count)
}
}
func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "서준")
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages"
unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin})
if unapproved.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 without approval at L1, got %d", unapproved.StatusCode)
}
approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true})
if approved.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with approval at L1, got %d", approved.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 1 {
t.Fatalf("expected exactly 1 persisted message (the approved one), got %d", count)
}
}
func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "가은")
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// No approved:true -- L2 + whitelist match should auto-send without it.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 auto-sending whitelisted topic at L2, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 1 {
t.Fatalf("expected 1 auto-sent message, got %d", count)
}
}
func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "도윤")
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + "/messages"
// Text doesn't match any whitelist keyword -- L2 falls back to L1
// behavior (approval required), it does not just allow or just block.
unapproved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin})
if unapproved.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for non-whitelisted topic at L2 without approval, got %d", unapproved.StatusCode)
}
approved := postJSON(t, convPath, sendMessageRequest{SenderID: senderID, Text: "주말에 영화 볼래?", SenderMode: SenderTwin, Approved: true})
if approved.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for non-whitelisted topic at L2 with approval, got %d", approved.StatusCode)
}
}
func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "은서")
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
// Whitelisted keyword happens to be the same word that also triggers
// the money escalation pattern in the mock AI service.
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403: escalation must override L2 whitelist match and approved:true, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("escalated message must never be sent regardless of level/whitelist/approval, found %d rows", count)
}
}
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "재민")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// A human typing their own money-related message is never gated --
// only unattended (twin) sends are.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "계좌번호 불러줄게",
SenderMode: SenderHuman,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for human-authored send, got %d", resp.StatusCode)
}
}
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(allModels...); err != nil {
t.Fatalf("migrate: %v", err)
}
unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
unreachable.Close() // closed immediately -- BaseURL now points at nothing listening
ai := &AIServiceClient{BaseURL: unreachable.URL, HTTP: http.DefaultClient}
gin.SetMode(gin.TestMode)
router := setupRouter(db, newConnectionManager(), ai)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
senderID := mustSignup(t, server.URL, "소연")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID,
Text: "ㅇㅇ 알겠어",
SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("expected 502 fail-closed when escalation gate is unreachable, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("must not persist a twin message when the safety gate couldn't be checked, found %d rows", count)
}
}
func TestDeleteUserPurgesData(t *testing.T) {
server, db := setupTestServer(t)
userID := mustSignup(t, server.URL, "유나")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: userID,
Text: "안녕",
})
if sendResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 sending message, got %d", sendResp.StatusCode)
}
db.Create(&WhitelistRule{UserID: userID, TopicKeyword: "저녁 약속"})
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("delete request: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["messages_deleted"].(float64) != 1 || out["escalation_logs_deleted"].(float64) != 1 {
t.Fatalf("unexpected purge summary: %v", out)
}
var userCount, msgCount, settingsCount, whitelistCount, logCount int64
db.Model(&User{}).Where("id = ?", userID).Count(&userCount)
db.Model(&Message{}).Where("sender_id = ?", userID).Count(&msgCount)
db.Model(&TwinSettings{}).Where("user_id = ?", userID).Count(&settingsCount)
db.Model(&WhitelistRule{}).Where("user_id = ?", userID).Count(&whitelistCount)
db.Model(&EscalationLog{}).Where("user_id = ?", userID).Count(&logCount)
if userCount != 0 || msgCount != 0 || settingsCount != 0 || whitelistCount != 0 || logCount != 0 {
t.Fatalf("expected all user-linked rows purged, got user=%d msg=%d settings=%d whitelist=%d log=%d",
userCount, msgCount, settingsCount, whitelistCount, logCount)
}
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("second delete request: %v", err)
}
if resp2.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 on repeat delete, got %d", resp2.StatusCode)
}
}
func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "지민")
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
vetoResp := postJSON(t, convBase+"/veto", nil)
if vetoResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 triggering veto, got %d", vetoResp.StatusCode)
}
// Would otherwise auto-send (L2 + whitelist match + approved), but the
// veto must override every other check.
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403: peer veto must override L2 whitelist + approval, got %d", resp.StatusCode)
}
var count int64
db.Model(&Message{}).Where("conversation_id = ?", conv.ID).Count(&count)
if count != 0 {
t.Fatalf("no twin message should be sent after veto, found %d rows", count)
}
}
func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "다은")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
postJSON(t, convBase+"/veto", nil)
resp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman})
if resp.StatusCode != http.StatusOK {
t.Fatalf("veto must not block the human's own messages, got %d", resp.StatusCode)
}
}
func TestVetoMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/veto", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "메트릭")
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderHuman})
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true})
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "계좌번호 알려줄게", SenderMode: SenderTwin})
conv2 := Conversation{IsGroup: false}
db.Create(&conv2)
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil)
resp, err := http.Get(server.URL + "/admin/metrics")
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["messages_human_total"].(float64) != 1 {
t.Fatalf("expected 1 human message, got %v", out["messages_human_total"])
}
if out["messages_twin_total"].(float64) != 1 {
t.Fatalf("expected 1 twin message, got %v", out["messages_twin_total"])
}
if out["escalations_total"].(float64) != 1 {
t.Fatalf("expected 1 escalation, got %v", out["escalations_total"])
}
byReason := out["escalations_by_reason"].(map[string]interface{})
if byReason["금전"].(float64) != 1 {
t.Fatalf("expected 1 금전 escalation, got %v", byReason)
}
if out["conversations_total"].(float64) != 2 || out["conversations_vetoed"].(float64) != 1 {
t.Fatalf("expected 2 conversations, 1 vetoed, got %v", out)
}
if out["peer_veto_rate"].(float64) != 0.5 {
t.Fatalf("expected 0.5 veto rate, got %v", out["peer_veto_rate"])
}
if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 {
t.Fatalf("expected at least 1 minted/used invite, got %v", out)
}
}
func TestWhitelistRuleCRUD(t *testing.T) {
server, _ := setupTestServer(t)
userID := mustSignup(t, server.URL, "화이트")
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"})
if createResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
}
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
if created["topic_keyword"] != "저녁" || created["contact_id"] != nil {
t.Fatalf("unexpected created rule: %v", created)
}
ruleID := uint(created["id"].(float64))
listResp, err := http.Get(base)
if err != nil {
t.Fatalf("list rules: %v", err)
}
var list map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&list)
rules := list["whitelist_rules"].([]interface{})
if len(rules) != 1 {
t.Fatalf("expected 1 rule listed, got %d", len(rules))
}
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
delResp, err := http.DefaultClient.Do(delReq)
if err != nil {
t.Fatalf("delete rule: %v", err)
}
if delResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
}
listResp2, _ := http.Get(base)
var list2 map[string]interface{}
json.NewDecoder(listResp2.Body).Decode(&list2)
if len(list2["whitelist_rules"].([]interface{})) != 0 {
t.Fatalf("expected 0 rules after delete, got %v", list2)
}
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
delAgainResp, _ := http.DefaultClient.Do(delAgain)
if delAgainResp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
}
}
func TestWhitelistRuleCRUDMissingUser(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestRetractTwinMessage(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "되돌리기")
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
strconv.FormatUint(uint64(conv.ID), 10)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer ws.Close()
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin})
if sendResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 auto-sending, got %d", sendResp.StatusCode)
}
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
var sendBroadcast map[string]interface{}
if err := ws.ReadJSON(&sendBroadcast); err != nil {
t.Fatalf("read send broadcast: %v", err)
}
if sendBroadcast["type"] != "message" {
t.Fatalf("expected type:message on send broadcast, got %v", sendBroadcast)
}
retractResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if retractResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 retracting, got %d", retractResp.StatusCode)
}
var retractBroadcast map[string]interface{}
if err := ws.ReadJSON(&retractBroadcast); err != nil {
t.Fatalf("read retract broadcast: %v", err)
}
if retractBroadcast["type"] != "retraction" || uint(retractBroadcast["id"].(float64)) != msgID {
t.Fatalf("unexpected retraction broadcast: %v", retractBroadcast)
}
var message Message
db.First(&message, msgID)
if !message.Retracted {
t.Fatalf("expected message.Retracted true after retract")
}
// Already retracted -- must not succeed again.
again := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if again.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 retracting an already-retracted message, got %d", again.StatusCode)
}
}
func TestRetractRejectsHumanMessage(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "사람")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
})
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 retracting a human message, got %d", resp.StatusCode)
}
}
func TestRetractMissingMessage(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/messages/9999/retract", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestDraftMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestDraftRequiresStyleSource(t *testing.T) {
server, db := setupTestServer(t)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestDraftProxiesToAIService(t *testing.T) {
server, db := setupTestServer(t)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out draftResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Status != "ok" || !strings.Contains(out.Text, "오늘 저녁에 뭐 먹을래") {
t.Fatalf("unexpected draft response: %+v", out)
}
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}

122
core-backend/models.go Normal file
View File

@ -0,0 +1,122 @@
package main
import "time"
type SenderMode string
const (
SenderHuman SenderMode = "human"
SenderTwin SenderMode = "twin"
)
type AutonomyLevel string
const (
AutonomyL0 AutonomyLevel = "L0"
AutonomyL1 AutonomyLevel = "L1"
AutonomyL2 AutonomyLevel = "L2"
)
type User struct {
ID uint `gorm:"primaryKey"`
InviteCode string `gorm:"uniqueIndex;not null"`
DisplayName string `gorm:"not null"`
CreatedAt time.Time
}
// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6
// "초대 기반 베타 가입 플로우") -- signup validates against this table instead
// of just deduping User.InviteCode, so joining actually requires a code
// someone handed out, not any never-used string.
type InviteCode struct {
ID uint `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;not null"`
CreatedAt time.Time
UsedAt *time.Time
UsedByUserID *uint
}
type Contact struct {
ID uint `gorm:"primaryKey"`
OwnerUserID uint `gorm:"not null;index"`
ContactUserID *uint
DisplayName string `gorm:"not null"`
RelationshipNote string
CreatedAt time.Time
}
// TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위
// 플래그(twin_disabled_by_peer)") -- per conversation, not per contact, so
// it lives here rather than on Contact. Set by POST /conversations/:id/veto
// when the counterpart asks to talk to the human only; checked before any
// twin auto-send in that conversation (main.go).
type Conversation struct {
ID uint `gorm:"primaryKey"`
IsGroup bool `gorm:"not null;default:false"`
TwinDisabledByPeer bool `gorm:"not null;default:false"`
CreatedAt time.Time
}
type ConversationParticipant struct {
ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
}
type Message struct {
ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"`
SenderID uint `gorm:"not null"`
SenderMode SenderMode `gorm:"not null;default:human"`
Text string `gorm:"not null"`
// Retracted is the one-tap undo for an L2 auto-send (PRD.md §3.1,
// AGENTS.md "every automatic action needs post-hoc notification +
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
// messages only.
Retracted bool `gorm:"not null;default:false"`
CreatedAt time.Time
}
type TwinSettings struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex;not null"`
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
CreatedAt time.Time
UpdatedAt time.Time
}
// WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the
// owner has approved for unattended replies. ContactID nil = any counterpart
// (PRD.md §3.1).
type WhitelistRule struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
ContactID *uint
TopicKeyword string `gorm:"not null"`
CreatedAt time.Time
}
// EscalationLog is one row per escalation_filter trigger -- the post-hoc
// notification + undo trail required by AGENTS.md's absolute safety
// invariants.
type EscalationLog struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
ConversationID uint `gorm:"not null;index"`
Reason string `gorm:"not null"`
MessageSnippet string `gorm:"not null"`
Resolved bool `gorm:"not null;default:false"`
CreatedAt time.Time
}
var allModels = []interface{}{
&User{},
&InviteCode{},
&Contact{},
&Conversation{},
&ConversationParticipant{},
&Message{},
&TwinSettings{},
&WhitelistRule{},
&EscalationLog{},
}

47
core-backend/relay.go Normal file
View File

@ -0,0 +1,47 @@
package main
import (
"sync"
"github.com/gorilla/websocket"
)
// ConnectionManager fans out messages to WebSocket connections per
// conversation. In-memory, single-process -- fine for a small closed beta
// (roadmap.md Phase 1); revisit if the relay needs to scale past one process.
type ConnectionManager struct {
mu sync.Mutex
conns map[uint][]*websocket.Conn
}
func newConnectionManager() *ConnectionManager {
return &ConnectionManager{conns: make(map[uint][]*websocket.Conn)}
}
func (m *ConnectionManager) add(conversationID uint, conn *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
m.conns[conversationID] = append(m.conns[conversationID], conn)
}
func (m *ConnectionManager) remove(conversationID uint, conn *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
peers := m.conns[conversationID]
for i, c := range peers {
if c == conn {
m.conns[conversationID] = append(peers[:i], peers[i+1:]...)
break
}
}
}
func (m *ConnectionManager) broadcast(conversationID uint, payload interface{}) {
m.mu.Lock()
peers := append([]*websocket.Conn(nil), m.conns[conversationID]...)
m.mu.Unlock()
for _, c := range peers {
_ = c.WriteJSON(payload)
}
}

156
docs/PLANNING.md Normal file
View File

@ -0,0 +1,156 @@
# 프로젝트 기획 접근 방법 — AI 분신 메신저
원본 회의 자료: [`idea-meeting-2026-06-29.html`](./idea-meeting-2026-06-29.html)
## 0. 지금 어디까지 왔나
회의 자료는 "발산 → 컨셉 탐색 → AI 분신으로 좁혀 설계 심화"까지 진행됐다.
즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다.
이 단계에서 실패하는 가장 흔한 패턴은 두 가지다.
- 자율성 5단계(L0~L4) + OS 레이어 확장 + 자체 메신저를 **동시에** 설계하려는 것 (범위 과다)
- 회의 자료의 "논점 Q1~Q7"을 확정하지 않은 채 기능 명세부터 쓰는 것 (순서 역전)
그래서 아래 프로세스는 "결정 → 좁히기 → 검증 → 명세" 순서를 강제하는 데 초점을 둔다.
## 1. 기획 프로세스 (5단계)
```
1) 컨셉 확정 회의 Q1~Q7에 실제로 답한다 (아래 §2)
2) 한 문장 정의 "누구를 위한, 무엇을, 왜" — Vision Doc 1페이지
3) MVP 시나리오 선정 4개 킬러 시나리오 중 "딱 하나"만 고른다 (§3)
4) PRD 작성 선정한 시나리오 기준으로만 기능/플로우/화면 정의
5) 기술 PoC 제일 위험한 가정부터 코드 없이/최소코드로 검증 (§4)
```
1→5는 순서대로, 단계를 건너뛰지 않는다. 특히 4(PRD)를 3(MVP 시나리오 선정) 전에 쓰면
자율성 5단계 전체를 다 설계하려는 함정에 다시 빠진다.
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다.
회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다.
| # | 질문 | 잠정 결정 | 상태 |
|---|------|------|------|
| Q1 | AI 분신으로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 |
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 |
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 |
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 |
| Q6 | 서비스 이름 | "분신" (가칭) | 제안 (가칭) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 |
## 3. MVP 시나리오 좁히기
회의 자료의 "킬러 시나리오" 4개를 검증 난이도·데모 임팩트 기준으로 비교한다.
| 시나리오 | 필요 자율성 | 데모 임팩트 | 기술 난이도 | 비고 |
|---|---|---|---|---|
| 🌙 읽씹 종결 | L0~L2 | 높음 (전 국민 공감) | 낮음 (컨텍스트 응답 1건) | **1순위 추천** — 가장 좁고 명확 |
| 📚 단톡 따라잡기 | L0 | 중간 | 낮음 (요약만) | 발송 권한 불필요, 안전 |
| 🚫 비호감 대화 방어 | L2 | 중간 | 중간 (의도 판별 필요) | 오탐 리스크 존재 |
| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (분신 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 |
**권장:** "읽씹 종결"과 "단톡 따라잡기"는 둘 다 L0~L2로 커버되고 같은 파이프라인(수신 메시지 요약 →
맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(분신 협상)는
로드맵의 다음 단계로 명시적으로 미룬다.
## 4. 기술 검증(PoC) 우선순위
기능을 만들기 전에, 이 아이디어를 무너뜨릴 수 있는 가정부터 코드로 확인한다.
1. **온디바이스 말투 학습이 실제로 "나답게" 느껴지는가** — 소규모 대화 샘플로 톤 재현 정도를 사람이 직접 평가
2. **안드로이드 알림 접근 권한(Notification Access)으로 읽기가 안정적인가** — 카톡 알림 파싱이
OS/앱 버전에 따라 얼마나 자주 깨지는지 확인 (OS 레이어 트랙을 갈 경우 필수)
3. **자동응대가 "이상하다"고 느껴지는 임계점은 어디인가** — 실제 대화 로그로 사용자 테스트, 뱃지·거부권
같은 투명성 장치가 실제로 신뢰를 회복시키는지 확인
4. **분신 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로
설계 문서 수준에서만 먼저 검증 (턴 수 제한, 타임아웃, 결렬 시 에스컬레이션 규칙)
## 5. 범위 관리 원칙 — "켜는 만큼만 만든다"
회의 자료의 자율성 사다리(L0~L4)는 제품 로드맵과 그대로 대응시킨다.
- **MVP**: L0(비서 모드) + L1(제안 모드) + L2(부분 위임, 좁은 화이트리스트 주제만)
- **v2**: L3(자리비움 응대) — 사용자 신뢰가 쌓인 뒤
- **v3+**: L4(분신 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤
절대 안전선(금전·약속 확정·민감 내용은 항상 사람에게 에스컬레이션)은 L0부터 L4까지 전 단계에서
예외 없이 지킨다 — 이건 범위를 줄여도 타협하지 않는 항목이다.
## 6. 자체 앱 vs OS 레이어 — 어디서 시작할까 (Q7)
두 트랙은 경쟁 관계가 아니라 순서 문제다.
- **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의
API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(분신의 자연스러움·신뢰)과 제3자
플랫폼 제약(카카오톡 등의 자동화 정책)이 동시에 섞여 실패 원인을 구분하기 어렵다.
- **자체 메신저(클로즈드 베타)**: 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해
통제된 소규모 그룹으로 핵심 가설만 순수하게 검증할 수 있다. "메신저를 옮겨야 한다"는 채택
장벽은 있지만, 베타 단계에서는 어차피 소규모 초대 기반이라 큰 문제가 아니다.
**최종 결정 (`decision-log.md` Q7 참고):** 자체 앱 클로즈드 베타를 먼저 만들어 핵심 가설을
검증하고, 검증되면 그 로직을 재사용해 OS 레이어(읽기 전용 허브, 문자·이메일 우선)로 확장해
채택 장벽을 낮춘다. 두 트랙을 동시에 만들지 않는다. (이 판단의 상세 근거는 `decision-log.md`
"Q7 상세 근거" 참고 — 이 섹션은 그 판단을 반영해 갱신되었다.)
## 7. 표준 산출물 세트
§2의 Q1~Q7에 잠정 결정을 내리고 아래 문서를 작성했다. 결정이 뒤집히면 이 문서들도 함께 갱신한다.
1. [`vision.md`](./vision.md) — 문제, 타깃, 가치제안 한 문장, 성공 지표
2. [`PRD.md`](./PRD.md) — MVP 시나리오 기준 기능 명세, 유저 플로우, 엣지 케이스
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (분신 사용자 관점)
10. [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의에서 Q1~Q7을 확정할 때 쓰는 1페이지 요약
## 8. 다음 액션 체크리스트
- [x] §2의 Q1~Q7 잠정 결정 — `decision-log.md` (실제 회의에서 재확인/뒤집기 필요)
- [x] Vision Doc 작성 — `vision.md`
- [x] MVP 시나리오 확정 (읽씹 종결 + 단톡 따라잡기) — `PRD.md`
- [x] PRD, 기술 설계서, 리스크 로그, 로드맵 작성
- [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md`
- [x] PoC 실행 준비물(모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지) 초안 작성 — `poc-materials.md`
(문서 준비만 완료. 실제 참가자 모집·데이터 수집·인터뷰 실행은 사람이 직접 해야 하는 단계 — 아직 미착수)
- [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건
확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는
아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고)
- [x] 라벨 없는 원천 대화 34,030건 추가 확보 — `poc/tone-corpus/build_unlabeled_corpus.py`
`unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용)
- [x] 개인화 레이어 설계 구체화 — `tech-design.md` §2-1 (v1은 커스텀 학습 없이 과거 발화 검색 +
few-shot, 기반 코퍼스는 평가/v2 온디바이스 증류용으로 역할 한정)
- [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출
전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93%
- [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 →
Gemini 호출로 초안 생성, 에스컬레이션 케이스는 하드 게이트로 거절). Cursor 환경에서 실제
`GEMINI_API_KEY`로 라이브 호출 성공 확인함 (스타일 예시 반말·ㅋ톤에 맞는 짧은 답장 초안 생성)
- [x] 블라인드 평가 자동화 스크립트 — `poc/tone-corpus/blind_eval.py`, 검증셋 대화를 자동으로
held-out 처리해 초안 생성 + 실제 답장을 나란히 리포트. 이 세션의 실행 도구 제한으로 직접
돌려보진 못함 — 실행 전 가벼운 샘플(`--n 5`)로 먼저 확인할 것
- [x] 말투 검색기(retrieval) 구현 — `poc/tone-corpus/retrieve_style.py` (키워드 자카드 유사도 +
최근성 가중치, 임베딩 없이 v1 설계 그대로). `generate_draft.py --history`로 연결해 스타일
예시를 손으로 안 골라도 자동 검색되게 함
- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 — **맨 마지막 작업으로 미룸** (`roadmap.md` §3)
- [x] `blind_eval.py`, `retrieve_style.py`, `generate_draft.py --history` 연동 실제 실행 검증 —
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
"말투 검색기" 참고)
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포).
PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md`
Phase 1 §1, 회의 필요

79
docs/PRD.md Normal file
View File

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

41
docs/decision-log.md Normal file
View File

@ -0,0 +1,41 @@
# 결정 로그
회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다.
상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를
쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진
하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를
그대로 실천하는 것이기도 하다.)
| # | 질문 | 결정 | 근거 | 상태 |
|---|------|------|------|------|
| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 |
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 |
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 |
| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 |
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 |
| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 |
## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가
`PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을
제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다.
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가,
신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다.
- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 —
버리는 작업이 아니다.
**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면
OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은
L3·L4 등 완전한 기능의 최종 목적지로 유지.
## 아직 열려 있는 하위 질문
- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은
실사용 데이터로 계속 조정 필요
- 상표/네이밍 최종안 (Q6은 가칭)
- 베타 참가자 모집 규모와 방식 (§로드맵 참고)

View File

@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>msn 메신저 아이디어 · 회의 자료</title>
<style>
:root{
--bg:#0c0e1a; --card:#15182a; --card2:#1c2038; --line:#2a2f4a;
--text:#e9ebf5; --muted:#9aa0c0;
--violet:#a78bfa; --cyan:#5ad1e6; --pink:#ff7eb6; --green:#5fe0a8; --amber:#ffcb6b; --red:#ff7a7a; --orange:#ffb056;
--accent:#a78bfa;
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{
font-family:"Segoe UI","Malgun Gothic",system-ui,sans-serif;
background:
radial-gradient(900px 500px at 15% -5%,#241a4a,transparent),
radial-gradient(800px 500px at 90% 0%,#10303a,transparent),
var(--bg);
color:var(--text); line-height:1.55;
}
.wrap{max-width:1120px;margin:0 auto;padding:0 20px}
/* sticky nav */
.topnav{
position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);
background:rgba(12,14,26,.82);border-bottom:1px solid var(--line);
}
.topnav .inner{max-width:1120px;margin:0 auto;padding:12px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.brand{font-weight:800;font-size:15px;letter-spacing:.02em}
.brand .dot{color:var(--violet)}
.navlinks{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap}
.navlinks a{
color:var(--muted);text-decoration:none;font-size:13.5px;font-weight:600;
padding:7px 13px;border-radius:999px;border:1px solid transparent;transition:.15s;
}
.navlinks a:hover{color:var(--text);border-color:var(--line);background:var(--card)}
/* hero */
.hero{text-align:center;padding:56px 20px 30px}
.kicker{
display:inline-block;letter-spacing:.18em;font-size:12px;color:var(--violet);
border:1px solid #33305e;background:rgba(167,139,250,.08);
padding:6px 14px;border-radius:999px;text-transform:uppercase;margin-bottom:18px;font-weight:700;
}
h1{font-size:42px;font-weight:800;letter-spacing:-.02em;margin-bottom:12px}
h1 .hl{background:linear-gradient(90deg,var(--violet),var(--cyan));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero .sub{color:var(--muted);font-size:16px;max-width:680px;margin:0 auto}
.goalbar{
display:inline-flex;gap:10px;align-items:center;margin-top:22px;flex-wrap:wrap;justify-content:center;
background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 20px;font-size:14px;color:var(--muted);
}
.goalbar b{color:var(--text)}
/* journey strip */
.journey{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin:26px auto 0;max-width:900px}
.jstep{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 16px;font-size:13.5px;color:var(--muted);flex:1;min-width:180px}
.jstep b{color:var(--text);display:block;margin-bottom:2px}
.jarrow{display:grid;place-items:center;color:var(--violet);font-weight:800;font-size:20px;animation:nudge 1.2s ease-in-out infinite}
@keyframes nudge{0%,100%{transform:translateX(0);opacity:.55}50%{transform:translateX(5px);opacity:1}}
/* section frame */
section{padding:42px 0;border-top:1px solid var(--line);margin-top:8px}
.sec-title{display:flex;align-items:center;gap:12px;margin-bottom:6px}
.sec-title .num{font-size:13px;font-weight:800;color:var(--bg);background:var(--violet);width:28px;height:28px;border-radius:8px;display:grid;place-items:center}
.sec-title h2{font-size:26px;font-weight:800}
.sec-desc{color:var(--muted);font-size:14.5px;margin:0 0 22px 40px}
.sub-h{display:flex;align-items:center;gap:10px;margin:30px 0 16px}
.sub-h .n{font-size:12px;font-weight:800;color:var(--bg);background:var(--cyan);width:24px;height:24px;border-radius:7px;display:grid;place-items:center}
.sub-h h3{font-size:19px;font-weight:800}
.sub-h .note{color:var(--muted);font-size:13px;margin-left:auto}
/* idea cards grid */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px}
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:22px 22px 12px;position:relative;overflow:hidden;transition:transform .15s,border-color .15s}
.card:hover{transform:translateY(-3px);border-color:#3a4150}
.card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--c)}
.card .tag{font-size:12px;font-weight:800;color:var(--c);letter-spacing:.04em;display:flex;align-items:center;gap:8px;margin-bottom:6px}
.card .cdot{width:8px;height:8px;border-radius:50%;background:var(--c)}
.card h4{font-size:18px;font-weight:800;margin-bottom:12px}
.card ul{list-style:none}
.card li{padding:10px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:3px}
.card li:first-of-type{border-top:none}
.name{font-weight:700;font-size:14.5px}
.desc{color:var(--muted);font-size:13px}
.cA{--c:var(--amber)} .cB{--c:var(--pink)} .cC{--c:var(--cyan)} .cD{--c:var(--green)} .cE{--c:var(--violet)}
.winners{margin-top:26px;background:linear-gradient(135deg,#231f0e,#1a1d24);border:1px solid #4a431d;border-radius:20px;padding:28px}
.winners h3{font-size:20px;margin-bottom:6px}
.winners h3 .star{color:var(--amber)}
.winners .lead{color:var(--muted);font-size:14px;margin-bottom:18px}
.wrow{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px}
.wcard{background:rgba(255,255,255,.03);border:1px solid var(--line);border-radius:14px;padding:18px}
.wnum{font-size:13px;font-weight:800;color:var(--amber);margin-bottom:8px}
.wcard .name{font-size:15.5px;margin-bottom:6px}
.wcard .why{color:var(--muted);font-size:13px}
/* highlight callout */
.pick{background:rgba(167,139,250,.08);border:1px solid #33305e;border-left:3px solid var(--violet);border-radius:12px;padding:16px 20px;margin-top:24px;font-size:14.5px}
.pick b{color:var(--violet)}
/* autonomy ladder */
.ladder{display:flex;flex-direction:column;gap:10px}
.rung{display:grid;grid-template-columns:60px 1.1fr 2fr 1.1fr;gap:14px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 18px;position:relative;overflow:hidden}
.rung::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--rc)}
.rung .lv{font-size:17px;font-weight:800;color:var(--rc)}
.rung .rname{font-weight:700;font-size:14.5px}
.rung .rdo{color:var(--muted);font-size:13px}
.pill{display:inline-block;padding:3px 10px;border-radius:999px;font-size:11.5px;font-weight:700}
.pill.no{background:rgba(154,160,192,.14);color:var(--muted)}
.pill.yes{background:rgba(95,224,168,.14);color:var(--green)}
.pill.both{background:rgba(90,209,230,.14);color:var(--cyan)}
.r0{--rc:#7c83a8}.r1{--rc:var(--cyan)}.r2{--rc:var(--green)}.r3{--rc:var(--amber)}.r4{--rc:var(--violet)}
.guard{margin-top:14px;background:rgba(255,122,122,.08);border:1px solid #5a3340;border-radius:12px;padding:14px 18px;font-size:13.5px;color:#ffd9d9;display:flex;gap:10px;align-items:flex-start}
.guard b{color:#fff}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:16px}
.box{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px 22px}
.box h5{font-size:15px;font-weight:800;margin-bottom:12px;display:flex;gap:8px;align-items:center}
.box ul{list-style:none}
.box li{padding:9px 0;border-top:1px solid var(--line);font-size:13.5px;color:var(--muted)}
.box li:first-child{border-top:none}
.box li b{color:var(--text)}
.mock{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px}
.bubble{padding:10px 14px;border-radius:14px;margin:8px 0;font-size:13.5px;max-width:88%}
.b-them{background:var(--card2);border:1px solid var(--line)}
.b-me{background:linear-gradient(135deg,#6d5dd3,#4a8fd6);margin-left:auto;color:#fff}
.b-twin{background:rgba(167,139,250,.12);border:1px dashed var(--violet);margin-left:auto}
.twinbadge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:800;color:var(--violet);margin-bottom:4px}
.mocknote{color:var(--muted);font-size:12px;margin-top:10px;text-align:center}
.vsbar{display:grid;grid-template-columns:1fr auto 1fr;align-items:stretch;background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;margin-top:6px}
.vsbar .side{padding:22px 24px}
.vsbar .mid{display:grid;place-items:center;padding:0 8px;background:var(--card2);font-weight:800;color:var(--muted)}
.vsbar h6{font-size:13px;color:var(--muted);margin-bottom:8px;font-weight:700}
.vsbar p{font-size:16px;font-weight:700}
.vsbar .l p{color:var(--cyan)} .vsbar .r p{color:var(--violet)}
.risks{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
.risk{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px}
.risk .q{font-weight:700;font-size:14px;color:#ffb4b4;margin-bottom:8px}
.risk .a{font-size:13px;color:var(--muted)}
.risk .a b{color:var(--green)}
.chips{display:flex;flex-wrap:wrap;gap:10px}
.chip{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 16px;font-size:14px}
.chip b{color:var(--violet)}
/* animated flow diagram */
.flowdiag{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px 18px 14px}
.flowsvg{width:100%;height:auto;display:block;overflow:visible}
.flowsvg .lane{fill:none;stroke-width:2.5;stroke-dasharray:8 7;animation:flowdash 1s linear infinite}
.flowsvg .lane.cyan{stroke:var(--cyan)}
.flowsvg .lane.violet{stroke:var(--violet)}
.flowsvg .lane.thin{stroke:#3a4060;stroke-width:2;stroke-dasharray:3 5;animation:none;opacity:.7}
@keyframes flowdash{to{stroke-dashoffset:-15}}
.flowsvg .dot{r:5}
.flowsvg .dot.cyan{fill:var(--cyan);filter:drop-shadow(0 0 5px var(--cyan))}
.flowsvg .dot.violet{fill:var(--violet);filter:drop-shadow(0 0 5px var(--violet))}
.flowsvg .lbl{fill:var(--muted);font-size:14px;font-weight:700;text-anchor:middle;font-family:inherit}
.flowsvg .nemoji{font-size:30px;text-anchor:middle}
.flowsvg .nlabel{fill:var(--text);font-size:15px;font-weight:800;text-anchor:middle;font-family:inherit}
.flowsvg .ncircle{fill:var(--card2);stroke:var(--line);stroke-width:2}
.flowsvg .ncircle.twin{stroke:var(--violet);stroke-width:2.5;filter:drop-shadow(0 0 8px rgba(167,139,250,.35))}
.flowsvg .centerlbl{fill:var(--amber);font-size:13.5px;font-weight:800;text-anchor:middle;font-family:inherit}
.flow-legend{display:flex;gap:20px;flex-wrap:wrap;justify-content:center;margin-top:10px;font-size:12.5px;color:var(--muted)}
.flow-legend span{display:inline-flex;align-items:center;gap:7px}
.lg-c,.lg-v{width:16px;height:3px;border-radius:2px;display:inline-block}
.lg-c{background:var(--cyan)}.lg-v{background:var(--violet)}
.diag-cap{text-align:center;color:var(--muted);font-size:13px;margin-top:12px}
.diag-cap b{color:var(--green)}
/* discussion */
.disc{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
.qcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 20px}
.qcard .qn{font-size:12px;font-weight:800;color:var(--cyan);margin-bottom:8px;letter-spacing:.05em}
.qcard .qt{font-size:15px;font-weight:700;margin-bottom:6px}
.qcard .qh{font-size:13px;color:var(--muted)}
/* OS layer section — layer-cake diagram */
.oslayer-badge{display:inline-flex;align-items:center;gap:8px;background:linear-gradient(135deg,rgba(167,139,250,.16),rgba(90,209,230,.12));border:1px solid #3a3670;border-radius:999px;padding:7px 16px;font-size:12.5px;font-weight:800;color:var(--violet);margin-bottom:16px}
.stackwrap{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:38px 26px 30px;position:relative}
.stack-row{display:flex;justify-content:center}
.stack-user{background:linear-gradient(135deg,#2a2340,#1c2038);border:1px solid #3a3670;border-radius:14px;padding:14px 30px;text-align:center;font-weight:800;font-size:15px}
.stack-user span{display:block;font-size:11.5px;font-weight:700;color:var(--muted);margin-top:3px}
.stack-arrow{display:flex;justify-content:center;color:var(--violet);font-size:20px;font-weight:800;margin:2px 0;opacity:.75}
.stack-twin{width:100%;max-width:640px;margin:0 auto;background:linear-gradient(90deg,rgba(167,139,250,.16),rgba(90,209,230,.16));border:1.5px solid var(--violet);border-radius:16px;padding:16px 22px;text-align:center;box-shadow:0 0 24px rgba(167,139,250,.18)}
.stack-twin b{font-size:15.5px}
.stack-twin span{display:block;color:var(--muted);font-size:12px;margin-top:4px}
.stack-apps{display:flex;justify-content:center;gap:12px;flex-wrap:wrap;margin-top:4px}
.app-chip{background:var(--card2);border:1px solid var(--line);border-radius:12px;padding:10px 16px;text-align:center;font-size:13px;font-weight:700;min-width:96px}
.app-chip .ico{font-size:20px;display:block;margin-bottom:4px}
.stack-cap{text-align:center;color:var(--muted);font-size:13px;margin-top:18px}
.stack-cap b{color:var(--green)}
/* roadmap (horizontal) */
.roadmap{display:flex;position:relative;gap:0;margin-top:6px}
.roadmap::before{content:"";position:absolute;top:19px;left:6%;right:6%;height:2px;background:var(--line);z-index:0}
.rstep{flex:1;text-align:center;padding:0 10px;position:relative;z-index:1}
.rstep .rnum{width:38px;height:38px;border-radius:50%;background:var(--violet);color:var(--bg);font-weight:800;font-size:14px;display:grid;place-items:center;margin:0 auto 12px}
.rstep .rtitle{font-weight:800;font-size:14px;margin-bottom:6px}
.rstep .rdesc{font-size:12.5px;color:var(--muted);line-height:1.5}
/* point cards (impact) */
.impactgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}
.impact{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px}
.impact .iico{font-size:22px;margin-bottom:8px}
.impact h6{font-size:14.5px;font-weight:800;margin-bottom:6px}
.impact p{font-size:13px;color:var(--muted)}
footer{text-align:center;color:var(--muted);font-size:13px;padding:40px 20px 70px;border-top:1px solid var(--line);margin-top:8px}
</style>
</head>
<body>
<!-- NAV -->
<div class="topnav">
<div class="inner">
<div class="brand">msn<span class="dot">.</span> 메신저 회의 자료</div>
<nav class="navlinks">
<a href="#overview">개요</a>
<a href="#board">아이디어 보드</a>
<a href="#twin">AI 분신 설계</a>
<a href="#oslayer">OS 레이어 확장</a>
<a href="#discuss">회의 논점</a>
</nav>
</div>
</div>
<!-- HERO -->
<div class="hero" id="overview">
<div class="wrap">
<span class="kicker">Idea Meeting · 2026.06.29</span>
<h1>카카오톡 대안 <span class="hl">메신저</span> 기획</h1>
<p class="sub">브레인스토밍한 전체 아이디어와, 그중 가장 특색 있는 <b>AI 분신 메신저</b> 설계를 한 곳에 모았습니다. 회의 진행용 자료입니다.</p>
<div class="goalbar">🎯 <span><b>최종 목표</b> — 대중·회사까지 아우르는 대표 메신저 &nbsp;|&nbsp; <b>핵심 축</b> — 메시지가 곧 행동이 되는 / 나를 대신하는 AI</span></div>
<div class="journey">
<div class="jstep"><b>1. 넓게 발산</b>5개 카테고리로 아이디어 브레인스토밍</div>
<div class="jarrow"></div>
<div class="jstep"><b>2. 특색 탐색</b>"한 문장으로 정의되는" 컨셉 4종 비교</div>
<div class="jarrow"></div>
<div class="jstep"><b>3. 선택 & 고도화</b>AI 분신 메신저로 좁혀 설계 심화</div>
</div>
</div>
</div>
<!-- SECTION 1: IDEA BOARD -->
<section id="board">
<div class="wrap">
<div class="sec-title"><span class="num">1</span><h2>아이디어 보드</h2></div>
<p class="sec-desc">카톡에 없거나 카톡이 못 하는 것들. 발산 단계에서 모은 전체 후보입니다.</p>
<div class="grid">
<div class="card cA">
<div class="tag"><span class="cdot"></span>A · 대화가 기록이 된다</div>
<h4>핵심 차별 축</h4>
<ul>
<li><span class="name">메시지 → 할일/결정/일정 승격</span><span class="desc">말풍선을 꾹 누르면 업무 데이터로 변환</span></li>
<li><span class="name">결정 로그 자동 누적</span><span class="desc">"그때 뭐로 정했지?"가 방마다 타임라인으로</span></li>
<li><span class="name">번복 추적</span><span class="desc">결정이 바뀌면 "이전: A" 히스토리가 따라붙음</span></li>
<li><span class="name">약속 자동 캘린더화</span><span class="desc">"금요일 3시 ㄱㄱ"에서 일정 후보 추출</span></li>
</ul>
</div>
<div class="card cB">
<div class="tag"><span class="cdot"></span>B · 읽씹·눈치 스트레스</div>
<h4>관계 피로 줄이기</h4>
<ul>
<li><span class="name">읽음 표시를 응답으로</span><span class="desc">"1" 대신 확인 / 할게요 / 나중에 버튼</span></li>
<li><span class="name">나만의 답장 마감 알림</span><span class="desc">"이따 답장" 누르면 본인에게만 리마인드</span></li>
<li><span class="name">나중에 모아보기</span><span class="desc">안 급한 방은 정해진 시간에 묶어 알림</span></li>
<li><span class="name">상태 자동화</span><span class="desc">회의 중·이동 중을 캘린더·위치로 자동</span></li>
</ul>
</div>
<div class="card cC">
<div class="tag"><span class="cdot"></span>C · 프라이버시·관계</div>
<h4>부담 없는 연결</h4>
<ul>
<li><span class="name">전화번호 없는 메신저</span><span class="desc">초대코드·링크로만 연결, 번호 노출 0</span></li>
<li><span class="name">관계별 분리 ID</span><span class="desc">회사용 나 / 친구용 나를 한 앱에서 분리</span></li>
<li><span class="name">종료 시한 대화방</span><span class="desc">프로젝트 끝나면 자동 보관·삭제</span></li>
<li><span class="name">사라지는 민감 메시지</span><span class="desc">비번·주소 등 읽고 나면 소멸</span></li>
</ul>
</div>
<div class="card cD">
<div class="tag"><span class="cdot"></span>D · AI 활용</div>
<h4>똑똑한 비서</h4>
<ul>
<li><span class="name">안 본 동안 요약</span><span class="desc">"자리 비운 사이 뭐 정해짐?" 3줄</span></li>
<li><span class="name">번역 없는 대화</span><span class="desc">한국어로 치면 상대는 영어로 수신</span></li>
<li><span class="name">톤 코치</span><span class="desc">공격적인 메시지 보내기 전 한 번 확인</span></li>
<li><span class="name">자료 찾아주기</span><span class="desc">"지난주 그 PDF 어딨지?" 자연어 검색</span></li>
</ul>
</div>
<div class="card cE">
<div class="tag"><span class="cdot"></span>E · 카톡이 못 박는 한 방</div>
<h4>기능 내장</h4>
<ul>
<li><span class="name">자료가 안 사라짐</span><span class="desc">파일·링크·사진을 방별 자료함에 자동 정리</span></li>
<li><span class="name">투표·정산 기본 내장</span><span class="desc">장소·시간 투표, n빵 정산을 앱 안에서</span></li>
<li><span class="name">가벼운 스레드</span><span class="desc">한 방에서 주제 섞여 난장판 되는 것 방지</span></li>
<li><span class="name">우리만의 검색</span><span class="desc">특정 친구와 나눈 것만 따로 검색·앨범화</span></li>
</ul>
</div>
</div>
<div class="winners">
<h3><span class="star"></span> "오 이건 카톡에 없네" 가 터지는 한 방 후보</h3>
<p class="lead">회사든 대중이든 모두가 공감하는 페인을 정확히 때리는 셋.</p>
<div class="wrow">
<div class="wcard"><div class="wnum">01</div><div class="name">번복 추적 결정 로그</div><div class="why">회의 뒤집기 짜증을 정확히 때림</div></div>
<div class="wcard"><div class="wnum">02</div><div class="name">읽씹을 응답 버튼으로</div><div class="why">전 국민이 겪는 눈치 스트레스 해소</div></div>
<div class="wcard"><div class="wnum">03</div><div class="name">나중에 모아보기 알림</div><div class="why">알림 폭격 피로를 근본적으로 해결</div></div>
</div>
</div>
<div class="pick">💡 <b>회의 결론(현재):</b> 위 아이디어들은 "기능 묶음"이라 정체성이 약하다는 판단 → 한 문장으로 정의되는 특색이 필요. 그래서 <b>"나 대신 말하는 AI 분신이 있는 메신저"</b>로 좁혀 아래에서 설계를 고도화함.</div>
</div>
</section>
<!-- SECTION 2: AI TWIN DESIGN -->
<section id="twin">
<div class="wrap">
<div class="sec-title"><span class="num">2</span><h2>AI 분신 메신저 · 설계 고도화</h2></div>
<p class="sec-desc">정체성 한 줄 — 챗GPT가 <b>나와 대화하는</b> AI라면, 분신은 <b>나를 대신해 남과 대화하는 나</b>다.</p>
<!-- 2-0 flow diagram (everyday loop) -->
<div class="sub-h"><span class="n"></span><h3>연결 구조 ① — 메시지는 이렇게 흐른다</h3><span class="note">화살표 방향으로 흐름이 움직입니다</span></div>
<div class="flowdiag">
<svg class="flowsvg" viewBox="0 0 960 330" xmlns="http://www.w3.org/2000/svg">
<!-- lanes -->
<path class="lane cyan" d="M180,120 L416,120" marker-end="url(#ahc1)"/>
<path class="lane violet" d="M420,205 L184,205" marker-end="url(#ahv1)"/>
<path class="lane cyan" d="M540,120 L776,120" marker-end="url(#ahc1)"/>
<path class="lane violet" d="M780,205 L544,205" marker-end="url(#ahv1)"/>
<defs>
<marker id="ahc1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#5ad1e6"/></marker>
<marker id="ahv1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#a78bfa"/></marker>
</defs>
<!-- labels -->
<text class="lbl" x="300" y="104">① 메시지 도착</text>
<text class="lbl" x="300" y="232">④ 나다운 응답</text>
<text class="lbl" x="660" y="104">② 요약·에스컬레이션</text>
<text class="lbl" x="660" y="232">③ 지시·승인</text>
<!-- moving dots -->
<circle class="dot cyan" r="5"><animateMotion dur="2.1s" repeatCount="indefinite" path="M180,120 L416,120"/></circle>
<circle class="dot cyan" r="5"><animateMotion dur="2.1s" begin="1.05s" repeatCount="indefinite" path="M180,120 L416,120"/></circle>
<circle class="dot violet" r="5"><animateMotion dur="2.1s" repeatCount="indefinite" path="M420,205 L184,205"/></circle>
<circle class="dot violet" r="5"><animateMotion dur="2.1s" begin="1.05s" repeatCount="indefinite" path="M420,205 L184,205"/></circle>
<circle class="dot cyan" r="5"><animateMotion dur="2.1s" begin="0.5s" repeatCount="indefinite" path="M540,120 L776,120"/></circle>
<circle class="dot violet" r="5"><animateMotion dur="2.1s" begin="0.5s" repeatCount="indefinite" path="M780,205 L544,205"/></circle>
<!-- nodes -->
<g><circle class="ncircle" cx="120" cy="162" r="52"/><text class="nemoji" x="120" y="156">🧑</text><text class="nlabel" x="120" y="184">상대</text></g>
<g><circle class="ncircle twin" cx="480" cy="162" r="52"/><text class="nemoji" x="480" y="156"></text><text class="nlabel" x="480" y="184">내 분신</text></g>
<g><circle class="ncircle" cx="840" cy="162" r="52"/><text class="nemoji" x="840" y="156">🙂</text><text class="nlabel" x="840" y="184"></text></g>
</svg>
<div class="flow-legend">
<span><i class="lg-c"></i> 들어오는 흐름 (상대 → 분신 → 나)</span>
<span><i class="lg-v"></i> 나가는 흐름 (나 → 분신 → 상대)</span>
</div>
<p class="diag-cap">분신이 1차로 받아 정리하고, <b>민감·중요한 건만 나에게 올린다</b>. 가벼운 건 ④에서 분신이 바로 응답.</p>
</div>
<!-- 2-0b flow diagram (twin negotiation, L4) -->
<div class="sub-h"><span class="n"></span><h3>연결 구조 ② — 분신끼리 약속을 조율한다 (L4)</h3><span class="note">두 분신이 직접 협상</span></div>
<div class="flowdiag">
<svg class="flowsvg" viewBox="0 0 960 250" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="ahc2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#5ad1e6"/></marker>
<marker id="ahv2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#a78bfa"/></marker>
</defs>
<!-- outer static links -->
<path class="lane thin" d="M158,135 L312,135"/>
<path class="lane thin" d="M648,135 L802,135"/>
<!-- center negotiation lanes -->
<path class="lane cyan" d="M412,108 L548,108" marker-end="url(#ahc2)"/>
<path class="lane violet" d="M548,162 L412,162" marker-end="url(#ahv2)"/>
<!-- center label -->
<text class="centerlbl" x="480" y="58">📅 서로의 캘린더를 보고 시간 조율</text>
<!-- moving dots -->
<circle class="dot cyan" r="5"><animateMotion dur="1.8s" repeatCount="indefinite" path="M412,108 L548,108"/></circle>
<circle class="dot violet" r="5"><animateMotion dur="1.8s" begin="0.9s" repeatCount="indefinite" path="M548,162 L412,162"/></circle>
<!-- nodes -->
<g><circle class="ncircle" cx="110" cy="135" r="46"/><text class="nemoji" x="110" y="130">🙂</text><text class="nlabel" x="110" y="156"></text></g>
<g><circle class="ncircle twin" cx="360" cy="135" r="46"/><text class="nemoji" x="360" y="130"></text><text class="nlabel" x="360" y="156">내 분신</text></g>
<g><circle class="ncircle twin" cx="600" cy="135" r="46"/><text class="nemoji" x="600" y="130"></text><text class="nlabel" x="600" y="156">상대 분신</text></g>
<g><circle class="ncircle" cx="850" cy="135" r="46"/><text class="nemoji" x="850" y="130">🧑</text><text class="nlabel" x="850" y="156">상대</text></g>
</svg>
<p class="diag-cap">두 분신이 협상한 뒤 <b>양쪽 본인에게 후보 시간만 제안</b> → 최종 확정은 사람이. (둘 다 앱이 있어야 작동 = 초대 동기)</p>
</div>
<!-- 2-1 ladder -->
<div class="sub-h"><span class="n">1</span><h3>분신 자율성 5단계</h3><span class="note">통제권은 항상 사용자에게 — 켜는 만큼만 일한다</span></div>
<div class="ladder">
<div class="rung r0"><div class="lv">L0</div><div class="rname">비서 모드</div><div class="rdo">나에게만 요약·할 일 정리·초안 제안. 발송은 안 함</div><div><span class="pill no">상대 모름</span></div></div>
<div class="rung r1"><div class="lv">L1</div><div class="rname">제안 모드</div><div class="rdo">답장 초안 생성 → 내가 한 탭으로 검토·발송</div><div><span class="pill no">상대 모름</span></div></div>
<div class="rung r2"><div class="lv">L2</div><div class="rname">부분 위임</div><div class="rdo">허락한 주제만 자동 응대 (약속 조율·택배 문의·간단 안내)</div><div><span class="pill yes">상대 표시</span></div></div>
<div class="rung r3"><div class="lv">L3</div><div class="rname">자리비움 응대</div><div class="rdo">자는 중·운전 중 가벼운 대화를 맥락에 맞게 대신</div><div><span class="pill yes">상대 표시</span></div></div>
<div class="rung r4"><div class="lv">L4</div><div class="rname">분신 협상</div><div class="rdo">상대 분신과 직접 조율 (캘린더 보고 약속 시간 잡기 등)</div><div><span class="pill both">양쪽 제안</span></div></div>
</div>
<div class="guard">🛡️ <div><b>절대 안전선:</b> 금전, 약속 확정, 감정적·민감한 내용은 어떤 단계에서도 자동 처리하지 않고 <b>무조건 본인에게 에스컬레이션</b>. 모든 위임 행동은 사후 알림 + 되돌리기 가능.</div></div>
<!-- 2-2 trust -->
<div class="sub-h"><span class="n">2</span><h3>신뢰·정체성 설계</h3><span class="note">"사칭 아니냐"는 의심을 투명성으로 정면 돌파</span></div>
<div class="grid2">
<div class="box">
<h5>🔎 투명성 원칙</h5>
<ul>
<li><b>분신 뱃지</b> — 분신 발화는 말풍선에 표식이 붙어 사람 발화와 시각 구분</li>
<li><b>실시간 확인</b> — 상대는 언제든 "지금 본인? 분신?" 물어볼 수 있음</li>
<li><b>확정 불가</b> — 분신은 금전·약속을 확정 못 하고 "본인 확인 필요"로 보류</li>
<li><b>거부권</b> — 상대가 "분신 말고 본인이랑만" 요청하면 자동 응대 중단</li>
</ul>
</div>
<div class="mock">
<div class="bubble b-them">자료 언제 줄 수 있어?</div>
<div class="bubble b-twin"><span class="twinbadge">✦ 지우님의 분신</span>지금 자리 비우셨고 보통 오전에 작업하세요. 내일 10시쯤 가능할 것 같아요 — 확정은 본인 확인 후 알려드릴게요!</div>
<div class="bubble b-them">ㅇㅋ 고마워</div>
<div class="bubble b-me">(지우 본인) 내일 10시 맞아요 :)</div>
<div class="mocknote">분신은 점선·뱃지로 구분, 확정은 본인이 마무리</div>
</div>
</div>
<!-- 2-3 personalization -->
<div class="sub-h"><span class="n">3</span><h3>"나다운" 분신 만들기</h3><span class="note">분신이 어색하면 컨셉 전체가 무너진다</span></div>
<div class="grid2">
<div class="box"><h5>🎨 말투 학습</h5><ul>
<li><b>온디바이스 우선</b> — 말투·표현·이모지 습관을 기기 안에서 학습</li>
<li><b>5분 온보딩</b> — 가입 시 기존 대화 임포트 or 질문 몇 개로 초기 세팅</li>
<li><b>교정 학습</b> — 분신 답을 고치면 학습해 점점 나다워짐</li>
</ul></div>
<div class="box"><h5>🎭 관계별 페르소나</h5><ul>
<li><b>톤 자동 전환</b> — 엄마엔 다정, 상사엔 정중, 친구엔 편하게</li>
<li><b>관계 메모</b> — 상대별 맥락(호칭·금기어·관계 온도) 기억</li>
<li><b>경계 설정</b> — "이 사람엔 분신 응대 끄기" 같은 관계별 규칙</li>
</ul></div>
</div>
<!-- 2-4 killer scenarios -->
<div class="sub-h"><span class="n">4</span><h3>분신이 빛나는 순간 (킬러 시나리오)</h3></div>
<div class="grid2">
<div class="box"><h5>🌙 읽씹 종결</h5><ul><li>자는 동안 온 메시지에 분신이 "지금 자는 중, 내일 답할게" 맥락 응대 → <b>읽씹·답장 압박 소멸</b></li></ul></div>
<div class="box"><h5>📅 약속 자동 조율</h5><ul><li>분신끼리 서로 캘린더를 보고 가능한 시간을 좁혀 <b>양쪽에 후보만 제안</b></li></ul></div>
<div class="box"><h5>🚫 비호감 대화 방어</h5><ul><li>영업·번호 캐묻기·치근덕거림을 분신이 정중히 막아 <b>감정 노동 대신</b></li></ul></div>
<div class="box"><h5>📚 단톡 따라잡기</h5><ul><li>밀린 단톡에서 <b>나 언급된 것만</b> 골라 요약 + 답장 초안 제시</li></ul></div>
</div>
<!-- 2-5 differentiation -->
<div class="sub-h"><span class="n">5</span><h3>기존 AI 비서와의 결정적 차이</h3></div>
<div class="vsbar">
<div class="side l"><h6>챗GPT · 시리 등</h6><p>나와 대화하는 AI</p></div>
<div class="mid">VS</div>
<div class="side r"><h6>분신 메신저</h6><p>나를 대신해 남과 대화하는 나</p></div>
</div>
<!-- 2-6 risks -->
<div class="sub-h"><span class="n">6</span><h3>리스크 & 안전장치</h3><span class="note">컨셉을 무너뜨릴 질문에 미리 답한다</span></div>
<div class="risks">
<div class="risk"><div class="q">분신이 틀린 약속을 잡으면?</div><div class="a">모든 위임 행동은 <b>사후 알림 + 원클릭 되돌리기</b>. 확정성 있는 건 본인 승인 전까지 "보류".</div></div>
<div class="risk"><div class="q">상대가 자동응대를 악용(스팸)?</div><div class="a">분신이 <b>스팸·도배 감지 시 응대 중단</b>하고 본인에게 보고. 무한 응답하지 않음.</div></div>
<div class="risk"><div class="q">두 분신이 무한 대화 루프?</div><div class="a">분신끼리 대화는 <b>목적·턴 수 제한</b>. 결론 안 나면 양쪽 본인에게 넘김.</div></div>
<div class="risk"><div class="q">프라이버시는?</div><div class="a">말투 학습은 <b>온디바이스/암호화</b>. 분신 끄기 항상 가능, 상대도 분신 거부 가능.</div></div>
</div>
<!-- 2-7 growth & revenue -->
<div class="sub-h"><span class="n">7</span><h3>확산 훅 & 수익 모델</h3></div>
<div class="grid2">
<div class="box"><h5>🌱 네트워크 효과</h5><ul>
<li><b>분신 협상(L4)은 둘 다 앱이 있어야 작동</b> → "네 분신이랑 약속 잡게 하자"가 초대 동기</li>
<li>나다운 분신은 쓸수록 똑똑해져 <b>이탈 비용</b> 발생</li>
</ul></div>
<div class="box"><h5>💳 수익 모델</h5><ul>
<li>기본 분신 <b>무료</b> (L0~L2)</li>
<li>고급 구독 — 더 똑똑한 모델, 더 넓은 위임(L3·L4), 다중 페르소나</li>
<li>기업용 — 고객 응대 분신 (B2B 확장)</li>
</ul></div>
</div>
<!-- 2-8 naming -->
<div class="sub-h"><span class="n">8</span><h3>이름 후보</h3><span class="note">"또 하나의 나"라는 정체성을 담은</span></div>
<div class="chips">
<div class="chip"><b>Echo</b> · 나를 따라 울리는</div>
<div class="chip"><b>Twin</b> · 또 하나의 나</div>
<div class="chip"><b>분신</b> · 직관적 한국어</div>
<div class="chip"><b>Aka</b> · 또 다른 이름(alias)</div>
<div class="chip"><b>둘이서</b> · 나와 분신이 함께</div>
<div class="chip"><b>Mirror</b> · 나를 비추는</div>
</div>
</div>
</section>
<!-- SECTION 2.5: OS LAYER IDEA -->
<section id="oslayer">
<div class="wrap">
<div class="sec-title"><span class="num">💡</span><h2>획기적 아이디어 · 분신을 OS 레이어로</h2></div>
<p class="sec-desc">"새 메신저 앱"이 아니라 <b>지금 쓰는 모든 대화 위에 얹히는 AI 레이어</b>로 재정의하면 어떨까. 채택 장벽 자체를 없애는 접근.</p>
<span class="oslayer-badge">✦ 카톡 대안이 아니라 — 모든 대화의 AI 비서</span>
<!-- before/after reframe -->
<div class="vsbar">
<div class="side l"><h6>지금 설계</h6><p>새 메신저 앱을 설치하고 옮겨와야 함</p></div>
<div class="mid"></div>
<div class="side r"><h6>OS 레이어 확장</h6><p>카톡·문자·DM 위에 분신만 얹으면 끝</p></div>
</div>
<!-- layer stack diagram -->
<div class="sub-h" style="margin-top:34px"><span class="n">1</span><h3>구조 — 분신이 관통하는 레이어</h3><span class="note">앱을 옮기는 게 아니라, 앱 위에 얹는다</span></div>
<div class="stackwrap">
<div class="stack-row"><div class="stack-user">🙂 나<span>모든 채널의 알림·초안·결정을 한곳에서 확인</span></div></div>
<div class="stack-arrow">↑ ↓</div>
<div class="stack-row"><div class="stack-twin"><b>✨ AI 분신 레이어</b><span>읽기·요약·초안·자동응대를 채널 구분 없이 동일하게 수행</span></div></div>
<div class="stack-arrow">↑ ↑ ↑ ↑ ↑</div>
<div class="stack-apps">
<div class="app-chip"><span class="ico">💬</span>카카오톡</div>
<div class="app-chip"><span class="ico">✉️</span>문자(SMS)</div>
<div class="app-chip"><span class="ico">📷</span>인스타 DM</div>
<div class="app-chip"><span class="ico">📧</span>이메일</div>
<div class="app-chip"><span class="ico">💼</span>슬랙</div>
</div>
<p class="stack-cap">각 앱은 그대로 두고, 분신이 <b>알림 접근 권한으로 관통</b>해 동일한 경험을 제공. 자체 메신저는 "완전판"으로 나중에 자연스럽게 유도.</p>
</div>
<!-- why this changes the game -->
<div class="sub-h"><span class="n">2</span><h3>왜 판이 바뀌는가</h3></div>
<div class="impactgrid">
<div class="impact"><div class="iico">🚪</div><h6>채택 장벽 제거</h6><p>"메신저 옮기기"라는 가장 큰 진입 장벽이 사라짐. 앱 설치 후 권한만 허용하면 바로 가치 체감.</p></div>
<div class="impact"><div class="iico">📈</div><h6>시장 크기 확대</h6><p>"카톡 대안 메신저" 시장이 아니라 "모든 대화의 AI 비서" 시장으로 — 타깃 인구 자체가 훨씬 커짐.</p></div>
<div class="impact"><div class="iico">🔗</div><h6>네트워크 효과 재설계</h6><p>상대가 앱이 없어도 나는 즉시 가치를 얻음(L0~L1). 분신 협상(L4)만 "둘 다 앱 있어야" 유효 — 압박감 없는 자연 확산.</p></div>
<div class="impact"><div class="iico">🏆</div><h6>독점적 진입점</h6><p>모든 채널의 요약·결정 로그가 한곳에 모이는 "허브"가 되면, 이후 자체 메신저·B2B 확장의 자연스러운 발판이 됨.</p></div>
</div>
<!-- rollout roadmap -->
<div class="sub-h"><span class="n">3</span><h3>단계적 도입 로드맵</h3><span class="note">권한이 큰 만큼, 신뢰를 먼저 쌓고 넓힌다</span></div>
<div class="roadmap">
<div class="rstep"><div class="rnum">1</div><div class="rtitle">읽기 전용 허브</div><div class="rdesc">알림만 모아 요약. 발송 권한 없음 — 가장 낮은 신뢰 비용으로 시작</div></div>
<div class="rstep"><div class="rnum">2</div><div class="rtitle">초안 제안</div><div class="rdesc">답장 초안을 만들어 각 앱으로 넘겨줌. 발송은 사람이 원래 앱에서</div></div>
<div class="rstep"><div class="rnum">3</div><div class="rtitle">제한적 자동응대</div><div class="rdesc">허락한 채널·주제에서만 분신이 직접 응답 (L2 수준 그대로 확장)</div></div>
<div class="rstep"><div class="rnum">4</div><div class="rtitle">자체 앱으로 유도</div><div class="rdesc">분신 협상(L4) 등 완전한 기능은 자체 메신저로 넘어와야 가능 — 자연스러운 업그레이드 동기</div></div>
</div>
<!-- technical/policy challenges -->
<div class="sub-h"><span class="n">4</span><h3>기술·정책 도전 과제</h3><span class="note">이 아이디어의 진짜 리스크는 여기에 있다</span></div>
<div class="risks">
<div class="risk"><div class="q">각 앱이 접근을 막으면?</div><div class="a">카톡·인스타는 자동화·크롤링을 정책상 막는 경우가 많음. <b>알림 접근 권한(안드로이드 Notification Access) 기반</b>으로 시작 — 읽기는 가능하나 발송은 각 앱 자체 API/자동입력에 의존해 범위가 제한적일 수 있음.</div></div>
<div class="risk"><div class="q">OS·플랫폼 정책 리스크는?</div><div class="a">iOS는 안드로이드보다 훨씬 제한적. <b>안드로이드 우선 출시</b>로 검증 후, iOS는 자체 메신저 전환을 유도하는 전략이 현실적.</div></div>
<div class="risk"><div class="q">권한 요청이 과도해 보이지 않을까?</div><div class="a">"모든 메시지를 읽는 앱"은 프라이버시 우려를 키움. <b>온디바이스 처리 원칙</b>을 이 레이어에도 그대로 적용하고, 어떤 데이터가 어디로 가는지 실시간 대시보드로 공개.</div></div>
<div class="risk"><div class="q">각 앱 UI가 바뀌면 깨지지 않을까?</div><div class="a">앱 업데이트마다 파싱 로직이 깨질 수 있음. 초기엔 <b>안정적인 API가 있는 채널(문자·이메일) 우선</b>으로 검증하고, 카톡·인스타 등은 이후 확장.</div></div>
</div>
<div class="pick">💡 <b>정리:</b> 이 아이디어는 "새 메신저를 만든다"에서 "지금 쓰는 대화 위에 AI 자아를 얹는다"로 제품 정의 자체를 바꾼다. 자체 메신저(현재 설계)는 <b>완전한 분신 경험(L4 협상 등)을 위한 최종 목적지</b>로 남기고, OS 레이어는 그곳으로 가는 <b>저마찰 진입점</b> 역할을 하는 투트랙 전략이 가능.</div>
</div>
</section>
<!-- SECTION 3: DISCUSSION -->
<section id="discuss">
<div class="wrap">
<div class="sec-title"><span class="num">3</span><h2>회의 논점</h2></div>
<p class="sec-desc">이 자료를 띄워놓고 함께 결정하면 좋을 질문들입니다.</p>
<div class="disc">
<div class="qcard"><div class="qn">Q1 · 방향</div><div class="qt">AI 분신으로 확정할까?</div><div class="qh">아이디어 보드의 다른 축(기록/관계)과 비교해 최종 컨셉을 정한다.</div></div>
<div class="qcard"><div class="qn">Q2 · 타깃</div><div class="qt">대중 vs 회사, 어디부터?</div><div class="qh">첫 사용자층을 정해야 기능 우선순위가 잡힌다.</div></div>
<div class="qcard"><div class="qn">Q3 · 수용성</div><div class="qt">사람들이 AI 대리 응답을 받아들일까?</div><div class="qh">자율성 어느 단계(L0~L4)까지 현실적으로 켤지 토론.</div></div>
<div class="qcard"><div class="qn">Q4 · 신뢰</div><div class="qt">사칭 우려를 충분히 막았나?</div><div class="qh">투명성 장치가 사용자를 납득시키는지 점검.</div></div>
<div class="qcard"><div class="qn">Q5 · MVP</div><div class="qt">데모로 보여줄 한 시나리오는?</div><div class="qh">읽씹 종결 / 약속 조율 / 대화 방어 중 하나를 고른다.</div></div>
<div class="qcard"><div class="qn">Q6 · 이름</div><div class="qt">서비스 이름을 뭐로?</div><div class="qh">정체성을 가장 잘 담는 후보를 추린다.</div></div>
<div class="qcard"><div class="qn">Q7 · 전략</div><div class="qt">자체 앱 vs OS 레이어, 어디서 시작?</div><div class="qh">채택 장벽을 낮추는 OS 레이어 선출시 vs 완전한 경험의 자체 메신저 선출시, 투트랙 순서를 정한다.</div></div>
</div>
</div>
</section>
<footer>msn 메신저 아이디어 회의 자료 · 아이디어 보드 + AI 분신 설계 통합본 — 회의 결과에 따라 선택 컨셉을 더 고도화하거나 MVP 범위를 좁혀갑니다.</footer>
</body>
</html>

View File

@ -0,0 +1,48 @@
# 회의 리뷰 요약 — Q1~Q7 확정 및 기획 문서 세트 검토용
이 회의의 목표: `decision-log.md`의 잠정 결정(Q1~Q7)을 실제로 **확정**하거나 **뒤집는다.**
그 외 문서(PRD, 기술설계 등)는 이 결정들 위에 이미 작성돼 있으므로, Q#이 뒤집히면 해당 문서도
같이 갱신해야 한다 — 회의 마지막에 담당자와 갱신 범위를 정한다.
## 지금까지 준비된 것 (한눈에)
| 문서 | 내용 | 상태 |
|---|---|---|
| `decision-log.md` | Q1~Q7 잠정 결정 + 근거 | **이 회의에서 확정 대상** |
| `vision.md` | 문제/타깃/가치제안/성공지표 | 결정 반영 완료 |
| `PRD.md` | v1 기능 명세, 유저 플로우, 엣지 케이스 | 결정 반영 완료 |
| `tech-design.md` | 아키텍처, 자율성 엔진, 투명성 구현 | 결정 반영 완료 |
| `risk-log.md` | 리스크 + 완화 상태 | 결정 반영 완료 |
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 |
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다.
## Q1~Q7 확정 체크리스트 (회의 중 하나씩)
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예
- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후)
- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설)
- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행)
- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예
- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전
- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예
## 회의에서 정할 것 (Q1~Q7 확정 외)
1. **PoC #1·#3 착수 일정**`poc-materials.md` 모집 문구를 누가, 언제 발송할지
2. **Q3 인터뷰(`user-interview-guide.md`) 담당자** — PoC#3과 같은 날 진행할지 여부
3. **Go/No-Go 기준 재확인**`poc-plan.md`의 수치 기준(예: "말투 같다" 4/5 이상)에 이견 없는지
4. **다음 리뷰 시점** — PoC 결과가 나오는 대로 재소집할지, 정기 주기로 할지
## 회의 후 팔로업
- Q#이 확정되면 `decision-log.md`의 "상태" 열을 제안 → 확정으로 갱신
- Q#이 뒤집히면 `decision-log.md` + 영향받는 하위 문서(§ 위 표 참고)를 같은 커밋에서 함께 수정
(`AGENTS.md`: "Q1~Q7이 뒤집히면 decision-log.md와 파생 문서를 동시에 갱신" 원칙)
- PoC 실행 담당자·일정이 정해지면 `PLANNING.md` §8 체크리스트에 담당자/일정 메모 추가

70
docs/poc-materials.md Normal file
View File

@ -0,0 +1,70 @@
# PoC 실행 준비물
`poc-plan.md`의 방법론을 실제로 돌리기 위해 바로 복사해서 쓸 수 있는 문구/스크립트 초안.
괄호 안 내용은 실행 담당자가 채워야 하는 부분이다.
## 1. PoC #1 참가자 모집 문구
> 안녕하세요! 요즘 만들고 있는 "분신"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나
> 잘 따라 하는지 테스트해보려고 해요. 참여 방법은 이렇습니다.
>
> 1. 최근 카톡 대화 내보내기(50~100개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요
> 2. 며칠 뒤에 "실제 답장 vs AI가 만든 답장"을 섞어서 보여드릴게요
> 3. 어느 게 진짜인지, 그리고 "이거 나 같다" 싶은지 짧은 설문에 답해주시면 끝!
>
> 10분 정도 걸리고, 원하시면 결과(AI가 얼마나 저를 잘 따라 했는지)도 같이 공유해드릴게요.
> 대화 내용은 이 테스트 외 다른 용도로 쓰지 않고, 끝나면 삭제할게요.
### 데이터 수집 동의 안내 (필수 포함 문구)
- 수집 목적: 온디바이스 말투 학습 프로토타입 품질 확인 (PoC #1)만
- 보관 기간: 테스트 종료 후 즉시 삭제, 최대 2주 보관
- 제3자 공유 없음, 참가자 본인 확인 후 언제든 삭제 요청 가능
- 민감한 대화(금전, 타인 언급, 사적인 내용)는 참가자가 사전에 직접 제외
### 블라인드 평가 설문 (초안)
| 항목 | 형식 |
|---|---|
| 아래 두 답장 중 실제로 보낸 것을 골라주세요 | 2지선다 (실제 답장 / AI 초안, 순서 랜덤) |
| "이 답장, 나(그 사람) 말투 같다" | 5점 척도 (1 전혀 아니다 ~ 5 매우 그렇다) |
| 어색하게 느껴진 부분이 있다면 자유롭게 | 자유 서술 |
| 실제 나였다면 이 답장을 그대로 보냈을까? | 3지선다 (그대로 보냄 / 조금 고쳐서 보냄 / 안 보냄) |
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)
```
상대: 오늘 좀 힘든 일이 있었어... 얘기해도 될까
분신: (감정적으로 무거운 주제로 판단, 보류)
죄송해요, 이런 얘기는 제가 대신 답하기 어려워서 지우님께 바로 알려드렸어요.
조금만 기다려주시면 지우님이 직접 답장하실 거예요.
(본인에게 알림: "은채님이 힘든 일이 있다고 하셨어요 — 직접 답장이 필요해요")
```
**관찰 포인트**: 분신이 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지,
아니면 오히려 더 신뢰하는지 — 감정 주제에서의 반응은 업무/약속 주제와 다를 수 있음.
### 역할극 진행 스크립트 (진행자용)
1. (도입, 1분) "지금부터 짧은 대화를 몇 번 나눠볼 거예요. 상대방이 되어 편하게 대화해주시면 됩니다."
2. (프로토타입 시연, 3분) 4개 탭을 순서대로 같이 보며 진행 — 매 장면 뒤 "지금 느낌이 어땠어요?" 짧게 질문
3. (시나리오 E 역할극, 3분) 진행자가 분신 역할, 참가자가 상대방 역할로 실제 대화
4. (사후 인터뷰, 5분) 아래 §3 질문지 사용
## 3. 사후 인터뷰 질문지 (PoC #3 공용)
1. 방금 대화가 불편하게 느껴진 순간이 있었나요? 있다면 어떤 부분이었나요?
2. "본인이야 분신이야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요?
3. 분신이 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요?
4. 거부권("본인이랑만 얘기하고 싶다")을 쓴다면, 언제 쓰고 싶을 것 같나요?
5. 이런 분신과 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
## 참가자 확보 규모 (재확인)
- PoC #1: 3~5명 (내부 팀/지인)
- PoC #3: 5~10명 ("상대방" 역할), 위 §1과 겹치지 않아도 됨
- 두 PoC를 같은 날 이어서 진행해도 무방 — 참가자 1인이 양쪽에 다 참여할 수도 있음

96
docs/poc-plan.md Normal file
View File

@ -0,0 +1,96 @@
# PoC 실행 계획 — #1 온디바이스 말투 학습 · #3 사칭/신뢰 수용성
`risk-log.md` 우선순위에 따라 이 두 PoC가 가장 시급하다: 이게 무너지면 나머지 기획(PRD, 로드맵)은
의미가 없다. 이 문서는 팀이 바로 실행할 수 있는 수준까지 방법론을 구체화한다. 실행 자체(실제 대화
데이터 수집, 참가자 인터뷰)는 이 문서 작성 시점에는 아직 이루어지지 않았다 — 다음 액션은 §4 참고.
## PoC #1 — 온디바이스 말투 학습이 "나답게" 느껴지는가
### 목적
`vision.md`의 핵심 가치제안("나 대신 나답게 응답")이 성립하는지 최소 비용으로 먼저 확인한다.
이게 실패하면 자율성 단계(L0~L2)나 신뢰 장치 설계는 전부 무의미해진다.
### 방법
1. **샘플 수집**: 참가자 3~5명당 최근 대화 50~100개(카카오톡 내보내기 등) 확보. 민감한 대화는
참가자가 직접 제외하도록 안내 (연구 목적 최소 수집 원칙, `tech-design.md` §5와 동일한 원칙 적용)
2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 분신
응답과 비교하지 않고 감춰둔 답)으로 나눈다
3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 분신이 응답 초안을 생성
4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 분신 초안"을
섞어서 보여주고 구분 가능한지 + "내(그의) 말투 같다"를 5점 척도로 평가
### 평가 기준 (Go/No-Go)
| 지표 | 기준 |
|---|---|
| "말투 같다" 평균 점수 | 4/5 이상 |
| 실제 답장과 분신 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) |
| 명백히 "이상하다"는 반응 비율 | 10% 미만 |
기준 미달 시: `vision.md`의 가치제안 자체를 재검토하거나, 온디바이스 학습 방식(특징 추출 방식,
학습 데이터량)을 먼저 개선하고 재시도. 재시도 없이 다음 단계(PRD 구현)로 넘어가지 않는다.
### 필요한 것
- 참가자 3~5명 (내부 팀/지인으로 충분, 이 단계는 소규모 신호 확인이 목적)
- **응답 초안 생성기** — [`poc/tone-corpus/generate_draft.py`](../poc/tone-corpus/generate_draft.py)로
준비됨. 말투 예시 몇 문장 + 최근 대화를 넣으면 LLM 호출로 답장 초안을 만든다 (배터리/성능 검증은
이 PoC의 범위가 아니라 서버 LLM 호출로 대체). 참가자 대화 샘플이 들어오면 그 사람의 실제 문장
몇 개를 `--style`에 넣어 바로 쓸 수 있음. 실행에는 `ANTHROPIC_API_KEY`가 필요 — 아직 이 세션엔
키가 없어 코퍼스 실제 대화로 프롬프트만 확인해둠 (`poc/tone-corpus/README.md` "샘플 검증" 참고)
- 블라인드 평가지 (설문 형태, 5점 척도 + 자유 코멘트)
- **기반 코퍼스**: AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋(14만여 건)을 확보해 개인화 전
일반 톤 생성 모델을 먼저 학습/평가할 수 있게 정리함 — [`poc/tone-corpus/`](../poc/tone-corpus/)
참고. 이건 개인화 검증(위 방법론)을 대체하지 않는다 — 익명 화자쌍의 일반 대화라 "내 말투 같다"는
질문에는 답을 못 준다. 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다.
- **개인화 메커니즘**: v1은 커스텀 모델 학습이 아니라 그 사람의 과거 발화를 검색해 few-shot으로
주입하는 방식 — `tech-design.md` §2-1에 구체화. PoC #1에서 수집한 참가자 샘플이 바로 이 메커니즘의
"말투 예시"가 된다.
## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX)
### 목적
`decision-log.md` Q4("사칭 우려 대응 충분한가?")는 아직 가설이다. 뱃지·거부권·확정 불가 원칙이
실제로 상대방의 신뢰를 지키는지, 설계 문서 밖에서 확인한다.
### 방법
1. **자극재 준비**: 읽씹 종결·본인확인·거부권·에스컬레이션 4개 장면을 실제로 눌러볼 수 있는 클릭
프로토타입을 이미 제작해둠 (`PLANNING.md` §8 참고). 역할극에서 이 프로토타입을 그대로 보여주며
"이런 상황이면 어떨 것 같은지" 반응을 끌어내는 자극재로 사용한다. 추가로 필요하면 감정적인
대화 등 프로토타입에 없는 케이스만 별도 스크립트로 보완
2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 분신과 대화하는
경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신
수행해도 됨, 이 단계에서는 UX/신뢰 반응만 측정)
3. **관찰 포인트**:
- 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가
- "본인이야 분신이야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가
- 거부권("본인이랑만 얘기하고 싶다")을 실제로 쓰고 싶어지는 순간이 있는가, 그 요청이 잘 반영됐다고 느끼는가
- 약속/금전 얘기가 나왔을 때 분신이 보류하는 것에 대한 반응 (안심 vs 답답함)
4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 분신과 대화하고 싶은가" 개방형 질문
### 평가 기준 (Go/No-Go)
| 지표 | 기준 |
|---|---|
| 뱃지 인지율 | 90% 이상 (인지 못하면 투명성 설계 자체가 실패) |
| "불편했다" 응답 비율 | 20% 미만 |
| 거부권 사용 의사 있었는데 실제로 못 쓴 경우 | 0건 (UX 결함으로 간주) |
기준 미달 시: `PRD.md` §3.1의 투명성 관련 기능(뱃지 표현, 본인확인 응답 문구, 거부권 노출 위치)을
먼저 개선. 이 PoC는 반복 가능해야 하므로 스크립트는 재사용 가능한 형태로 `docs/poc-plan.md`에 계속
누적한다.
## 결과를 문서에 반영하는 방법
두 PoC 모두 결과가 나오면:
1. `decision-log.md`의 해당 Q(Q3, Q4)를 "제안" → "확정" 또는 "재검토 필요"로 갱신
2. Go 판정이면 `PLANNING.md` §8 체크리스트에 완료 표시
3. No-Go면 `risk-log.md`에 구체적 실패 모드를 새 리스크 행으로 추가하고, 재시도 계획을 이 문서에 추가
## 다음 액션 (이 문서 작성 다음 단계)
바로 쓸 수 있는 모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지는 [`poc-materials.md`](./poc-materials.md)에 준비되어 있다.
- [x] PoC #1: 참가자 모집 문구 + 데이터 수집 동의 안내 초안 — `poc-materials.md` §1
- [x] PoC #3: 역할극 스크립트(프로토타입 보완용) + 사후 인터뷰 질문지 초안 — `poc-materials.md` §2~3
- [ ] PoC #1: 참가자 3~5명 실제 확보 + 대화 샘플 수집 (사람이 직접 섭외해야 하는 단계)
- [ ] PoC #1: 응답 초안 생성 프로토타입 준비 (LLM 프롬프트 기반 초기 버전으로 충분)
- [ ] PoC #3: "상대방" 역할 참가자 5~10명 실제 확보
- [ ] 두 PoC 결과를 `decision-log.md`/`risk-log.md`에 반영

22
docs/risk-log.md Normal file
View File

@ -0,0 +1,22 @@
# 리스크 로그
회의 자료(§2-6, §oslayer §4)와 `PRD.md`/`tech-design.md` 작성 중 식별된 리스크를 한곳에서
추적한다. 완화 방안이 "설계상 반영됨"인 항목도 실사용 검증 전까지는 가설이다.
| 리스크 | 영향 | 완화 방안 | 상태 |
|---|---|---|---|
| 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 |
| 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — 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 |
| 배터리/성능 부담 (온디바이스 추론) | 사용자 이탈 | 경량 모델 우선, 부족 시 서버 폴백 | 미검증 |
| 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 |
| (v2 대비) 카카오톡/인스타 알림 파싱이 앱 업데이트로 깨짐 | OS 레이어 확장 시 유지보수 부담 | v1 범위에서 제외, v2 착수 시 안정적 API 채널(문자·이메일) 우선 | v1 범위 밖 — 그대로 유지 |
| (v2 대비) iOS 플랫폼 정책 제약 | OS 레이어 iOS 확장 어려움 | 안드로이드 우선 검증 후 iOS는 자체 앱 전환 유도 | v1 범위 밖 — 그대로 유지 |
| PoC #1 기반 코퍼스(AI-Hub) 재배포/보관 리스크 | 이용약관 위반, 데이터 유실 | git에 커밋 금지(`.gitignore` 반영), 원본·가공본 모두 세션 로컬에만 두고 영구 저장소로는 별도 이동 | 설계 반영 — `poc/tone-corpus/README.md` |
## 우선순위
지금 시점에서 가장 먼저 확인해야 할 두 가지는 **온디바이스 말투 학습 품질**과 **사칭/신뢰 수용성**이다.
이 둘이 무너지면 나머지 리스크 완화는 의미가 없다. `PLANNING.md` §4의 PoC #1, #3을 최우선으로 진행한다.

182
docs/roadmap.md Normal file
View File

@ -0,0 +1,182 @@
# 로드맵 & 마일스톤
`decision-log.md`의 Q3(자율성 단계), Q7(자체앱→OS레이어 순서)에 따른 단계별 계획.
각 단계는 이전 단계의 핵심 가설이 검증되어야 다음으로 넘어간다 — 일정보다 검증 결과가 게이트다.
## Phase 0 — 기술 PoC (착수 즉시)
- 온디바이스 말투 학습 품질 검증 (`PLANNING.md` §4 PoC #1)
- 에스컬레이션 판정기(규칙 기반) 최소 프로토타입으로 오탐/누락 감 잡기
- 목표: "분신이 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보
## Phase 1 — v1 클로즈드 베타 (자체 앱, `PRD.md` 범위)
- 자율성 L0~L2, 시나리오: 읽씹 종결 + 단톡 따라잡기
- 안드로이드 우선, 초대 기반 소규모 베타 (지인 네트워크)
- 게이트: `vision.md` 성공 지표(자연스러움 70%, 거부율 10% 미만, 안전선 위반 0건) 충족 여부
### Phase 1 상세 작업 분해
원칙상 Phase 0(PoC) 검증 후 착수하는 게 맞지만, PoC 실제 실행(참가자 모집)이 보류된 지금
**PoC 결과와 무관한 기반 작업은 병행 착수**하고, **PoC 결과가 있어야 정할 수 있는 세부 값**은
자리만 비워두고 나중에 채우는 방식으로 진행한다. 아래 §3이 그 경계선이다.
이 체크리스트가 Phase 1 작업의 단일 기준이다 — 작업을 시작하기 전에 여기서 다음 항목을 확인하고,
끝나면 체크하고, 새로 발견한 하위 작업은 해당 항목 밑에 추가한다 (`AGENTS.md` "Phase 1 앱 빌드
작업 규칙" 참고).
#### 1. 착수 전 확정 필요 (기술 스택)
- [x] 기술 스택 결정 — `tech-design.md` §8 (Flutter/Dart 클라이언트, Go 코어 백엔드 +
Python AI 서비스 투트랙, PostgreSQL, WebSocket 릴레이, drift+SQLCipher, Gemini 키 분리)
#### 2. 워크스트림별 작업
**2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능)
- [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함
- [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함.
멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
- [ ] 푸시 알림 서비스 연동
`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것.
**2.2 AI 서비스** (Python, PoC 스크립트 → 내부 API로 승격)
- [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는
FastAPI 서비스로 승격 — `ai-service/` (`POST /draft`, style_examples/history 두 경로 +
에스컬레이션 하드게이트 + 검증 오류 전부 실제 테스트로 확인함)
- [x] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용)
`core-backend/aiservice.go`(`AIServiceClient.requestDraft`) + `POST /conversations/:id/draft`
라우트, mock AI 서비스로 정상 프록시·404·400(스타일 소스 없음) 전부 실제 테스트로 확인함
- [x] 자율성 엔진(L0~L2) 오케스트레이션 최소 버전 — 2.5 QA에서 "자율성 플로우 통합 테스트"를 쓰려면
실제 분기 로직이 있어야 해서 그때 구현함. 결정: 에스컬레이션 하드게이트(Go 코어, 이미 구현) →
레벨 확인은 Go 코어 책임(`PATCH /users/:id/twin-settings`로 레벨 변경, 메시지 저장 시 레벨별 분기).
L0 항상 차단, L1은 `approved:true` 필요, L2는 화이트리스트 매칭 시 즉시 자동발송·매칭 없으면 L1과
동일. 에스컬레이션은 레벨/화이트리스트 무관 항상 우선. **간소화한 부분**: 검색(retrieve)→초안
생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
**되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직.
에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가)
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/`
- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접
입장. WebSocket 수신·인간 메시지 전송 연결됨
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은
**사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만)
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은
서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만
- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼.
사후알림 전용 함/푸시는 아직 없음
**2.4 안전장치 통합** (전 구간 필수, 타협 불가)
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
구멍: `POST /conversations/:id/messages``sender_mode: "twin"`을 검증 없이 그대로 저장·
브로드캐스트하고 있었음(초안 생성(`/draft`)만 게이트를 탔고, 발송 자체는 게이트가 없었음). 이걸
막기 위해 `ai-service``/draft`와 별개인 `POST /escalate/check` 하드게이트 엔드포인트를 추가하고,
`core-backend`가 트윈 발송 저장 *직전에* 무조건 이걸 호출하도록 만듦 — 어떤 경로로 왔든 발송이
실제로 일어나는 단 하나의 지점(메시지 저장)에서 걸리므로 클라이언트가 뭘 하든 우회 불가.
AI 서비스 응답 불가 시 fail-safe(발송 차단, 502)로 처리. 사람이 직접 보내는 메시지는 게이트 대상이
아님. 차단·통과·게이트 불능 3케이스 전부 실제 테스트로 확인함 (`core-backend/main_test.go`)
- [x] **발견 및 수정**: 거부권(peer veto) 안전 불변식이 코드에 전혀 구현되어 있지 않았음 —
`Contact.TwinDisabledByPeer` 필드만 스키마에 있고 어디서도 읽거나 쓰지 않았고, `tech-design.md`
§4는 "대화방 단위 플래그"라는데 실제로는 상대(Contact) 단위로 모델링돼 있어 설계 문서와도
불일치했음(2.6 작업 중 발견). `Conversation.TwinDisabledByPeer`로 옮기고 `POST
/conversations/:id/veto` 추가, 메시지 발송 시 **거부권 → 에스컬레이션 → 자율성 레벨** 순으로
체크(거부권이 전부보다 우선) — L2 화이트리스트 매칭 + `approved:true`여도 거부권이 켜져 있으면
무조건 차단되는 것까지 테스트로 확인함
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업
**2.5 QA/테스트**
- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도
동일하게 — `ai-service/tests/`(pytest, 34개), SELFTEST_CASES 승격 + Gemini 호출 mock + `/health`·
`/escalate/check`·`/draft` FastAPI 엔드포인트 테스트까지 포함. `poc/tone-corpus/`의 ad-hoc
`--selftest`는 실험 도구로 그대로 두고(승격 대상은 "실제 서비스"인 `ai-service/`), 별개로 유지
- [x] 자율성 플로우(L0→L1→L2) 통합 테스트 — `core-backend/main_test.go`. 위 2.2 최소 오케스트레이션
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
전부 실제 테스트로 확인함
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속)
**2.6 베타 배포 준비**
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고
`/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자
인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것)
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
`/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
- [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics`
카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는
노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고,
지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지
않고 명시적으로 비워둠)
#### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막**
**사람 대상 PoC #1/#3·Q3 인터뷰는 지금 실행할 수 없으므로 맨 마지막 작업으로 미룬다.**
§2 워크스트림(서버+Flutter)과 푸시/세션 등 남은 인프라가 끝난 뒤에만 이 섹션으로 돌아온다.
PoC 데이터 없이 기본값을 추측해 채우지 않는다.
- [ ] 사람 PoC #1/#3 실제 실행 + Q3 인터뷰 (참가자 모집 포함) — **맨 마지막**
- [ ] 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요
- [ ] 화이트리스트 기본 주제 목록 — 실사용 데이터 필요
- [ ] 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요
- [ ] 실제 베타 오픈 시점 — `vision.md` 게이트 통과 필요
#### 4. 권장 착수 순서 (진행 상황)
순서대로 하나씩 완료하고 다음으로 넘어간다. **사람 PoC(§3)는 1~5번이 전부 끝난 뒤 맨 마지막.**
1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 —
설계 참고용으로 남기고 Go로 포팅 필요)
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음)
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
§3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**)
## Phase 2 — L3 확장 + 베타 확대
- 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만
- 관계 메모, 답장 마감 알림 등 P1 기능
- 베타 규모를 소규모 지인 네트워크 밖으로 확대
## Phase 3 — OS 레이어 진입 (성장 전략)
- 전제: Phase 1 자체 앱 베타에서 핵심 가설이 검증된 뒤에만 착수 (`decision-log.md` Q7)
- 읽기 전용 허브부터 (발송 권한 없음, 문자·이메일 등 안정적 API 채널 우선)
- OS 레이어 내부 순서: 읽기 전용 → 초안 제안 → 제한적 자동응대(L2 그대로 확장)
- 확산 후 완전한 기능(L3·L4 등)이 필요하면 자체 앱으로 유도 — 시작 순서를 뒤집는 뜻이 아님
- 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응
## Phase 4 — L4 분신 협상 + B2B 확장
- 사용자 기반이 어느 정도 쌓여 네트워크 효과가 의미 있을 때 착수
- 기업용 고객 응대 분신(B2B)은 이 시점 이후 별도 트랙으로 검토
## 명시적으로 지금 계획하지 않는 것
- Phase 1 게이트를 통과하기 전에 Phase 2 이후 기능을 설계/개발하지 않는다.
- OS 레이어와 자체 앱을 동시에 만들지 않는다 (`decision-log.md` Q7 근거).

137
docs/tech-design.md Normal file
View File

@ -0,0 +1,137 @@
# 기술 설계서 — 분신 v1
`PRD.md` 범위(자체 앱, L0~L2, 읽씹 종결 + 단톡 따라잡기)를 구현하기 위한 아키텍처 개요.
상세 API 스펙이 아니라 경계와 원칙을 정의하는 문서다 — 실제 구현 시 세부 설계는 별도로 좁혀나간다.
## 1. 전체 구조
```
[클라이언트 앱 (Android 우선, iOS 병행 검토)]
├─ 메시지 UI / 뱃지 렌더링
├─ 온디바이스 말투 모델 (경량, 로컬 추론)
├─ 자율성 엔진 (L0~L2 규칙 + 에스컬레이션 판정)
└─ 로컬 이벤트 로그 (사후 알림 / 되돌리기용)
│ (암호화된 동기화만, 원문은 최소 전송)
[서버]
├─ 메시지 릴레이 / 대화방 저장
├─ 응답 초안 생성 (온디바이스로 부족한 경우의 폴백 LLM 호출)
└─ 화이트리스트·설정 동기화 (기기 간)
```
## 2. 온디바이스 vs 서버 경계
- **말투 학습(온보딩, 교정 학습)은 온디바이스 우선.** 원문 대화를 서버로 올리지 않고
기기 내에서 스타일 특징만 추출·저장한다. (`vision.md`의 신뢰 가설과 직결 — 여기서 타협하면
Q4 사칭/프라이버시 우려가 그대로 리스크로 남는다)
- **응답 초안 생성은 온디바이스 우선 + 서버 폴백.** 기기 성능/배터리 제약으로 온디바이스 모델이
처리 못 하는 경우에만 서버 LLM 호출, 이 경우도 필요한 최소 컨텍스트만 전송.
- **자율성 엔진(L0~L2 판정, 에스컬레이션 규칙)은 클라이언트에 둔다.** 금전/약속/민감 감지가
서버 왕복 지연이나 서버 장애에 영향받지 않아야 안전선이 항상 지켜진다.
- **대화방 저장/릴레이는 서버.** 멀티 디바이스 동기화, 상대방에게 메시지를 전달하는 기본 기능.
### 2-1. 개인화 레이어 — 실제로 어떻게 "그 사람 말투"가 되는가
v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 기반 few-shot**으로 개인화한다 —
[`poc/tone-corpus/generate_draft.py`](../poc/tone-corpus/generate_draft.py)가 이미 구현한
"말투 예시 + 대화 맥락 → LLM 호출" 방식을 그대로 쓰되, 말투 예시를 고정 목록이 아니라
**그 순간 대화와 가장 비슷한 과거 발화 5~8개를 그 사람의 메시지 이력에서 검색해 넣는다.**
1. **온디바이스**: 사용자의 과거 메시지(온보딩 시 임포트한 50~100개 + 계속 쌓이는 실사용 이력)를
기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일)
2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성
기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩
인프라를 먼저 만들지 않는다). 구현: [`poc/tone-corpus/retrieve_style.py`](../poc/tone-corpus/retrieve_style.py)
— 자카드 유사도 + 최근성 가중치. `generate_draft.py --history`로 바로 연결됨
3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버
LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로
교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다
**기반 코퍼스(AI-Hub 14.2만 건, `poc/tone-corpus/`)의 역할은 이 개인화 메커니즘 자체가 아니다.**
호스팅된 LLM(Gemini)이 이미 일반적인 한국어 대화 유창성을 갖고 있어서, v1에 별도로 코퍼스를
학습시킬 필요가 없다. 이 코퍼스는 두 가지로만 쓴다:
- **평가**: 검증셋으로 "일반적인 한국어 SNS 대화로서 얼마나 자연스러운가"를 벤치마크 (개인화
여부와 무관한 기초 품질 체크 — `poc/tone-corpus/README.md` "샘플 검증"이 그 예)
- **v2 이후 온디바이스 증류 대비**: 배터리/지연/프라이버시 압박으로 자체 경량 모델이 필요해지면,
이 코퍼스가 그 모델의 기반 학습 데이터가 된다. v1 시점에는 착수하지 않는다
## 3. 자율성 엔진 (L0~L2)
1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류)
2. 에스컬레이션 대상이면 무조건 사용자에게 알림만 하고 종료 (자동 처리 안 함) — **이 경로엔 예외 없음**
3. 아니면 자율성 레벨 확인:
- L0: 초안만 생성해 사용자에게 보여줌, 발송 없음
- L1: 초안 생성 + 발송 승인 요청 알림
- L2: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등
4. 발송된 모든 자동 응답은 로컬 이벤트 로그에 기록 (사후 알림 + 되돌리기 버튼 노출)
에스컬레이션 판정기는 v1에서는 규칙 기반(키워드 + 간단한 의도 분류) + 온디바이스 모델의 결합으로
시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면
항상 에스컬레이션 쪽으로 fail-safe.
규칙 기반 1차 게이트는 [`poc/tone-corpus/escalation_filter.py`](../poc/tone-corpus/escalation_filter.py)로
구현해뒀다 — `generate_draft.py`가 LLM을 부르기 전에 먼저 통과해야 하며, 걸리면 LLM 호출 자체를
건너뛴다. 검증셋 82,305개 발화로 측정한 트리거율은 0.93% (`poc/tone-corpus/README.md` 참고) — 실제
1:1 사적 대화가 아닌 일반 SNS 코퍼스 기준이라 상한선 참고용이다.
## 4. 투명성 구현
- **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용
(서버가 신뢰의 원천 — 클라이언트 임의 조작 방지를 위해 서명 포함)
- **실시간 본인 확인**: "본인/분신" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리,
분신은 항상 정직하게 "저는 분신입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정)
- **거부권**: 대화방 단위 플래그(`twin_disabled_by_peer`) — 상대방의 거부 요청이 감지되면 즉시
해당 대화방에서 L1/L2 자동 동작을 끄고 L0으로 강등
## 5. 데이터/프라이버시 원칙
- 원문 대화 내용은 기본적으로 기기 내 저장, 서버는 릴레이에 필요한 최소 기간만 보관
- 말투 특징 벡터 등 학습 산출물은 암호화 저장, 사용자가 언제든 초기화 가능
- 어떤 데이터가 어디로 가는지 설정 화면에서 실시간으로 확인 가능한 대시보드 제공 (베타 초기엔
최소한 "이 대화는 서버로 갔음/기기에만 있음" 표시 수준으로 시작)
## 6. 플랫폼 전략
- **Android 우선 (v1은 Android만).** 근거 세 가지:
1. v2 OS 레이어(알림 접근 권한) 확장이 애초에 안드로이드에서만 가능함 — 애플이 다른 앱의
알림 내용을 읽는 것 자체를 정책적으로 막고 있어, 클라이언트 프레임워크와 무관하게 구조적으로
안드로이드 전용인 기능임
2. 개발 환경이 Windows라 iOS 빌드(Xcode/macOS 필요)를 할 수 없음 — v1 시점의 실질적 제약
3. Flutter라 iOS 코드 재작성 비용 자체는 낮지만, 위 두 이유로 지금은 굳이 열 이유가 없음
- iOS는 빌드 가능한 환경(Mac)이 갖춰지거나 베타 반응을 보고 병행 착수 여부를 다시 판단한다.
Flutter를 쓰므로 그때 가서 UI를 다시 만들 필요는 없고, iOS 빌드·서명·APNs 설정 등 플랫폼별
작업만 추가하면 된다.
## 7. v1에서 의도적으로 안 만드는 것
- OS 레이어(알림 파싱 기반 타 앱 관통) — v2. 지금 만들면 카카오톡 등 UI 변경에 따라 계속 깨지는
파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다.
- 분신 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음.
- 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충.
## 8. 기술 스택 결정 (Phase 1)
`roadmap.md` Phase 1 §1의 "착수 전 확정 필요" 항목에 대한 결정. PoC 데이터와 무관하게 지금
확정할 수 있는 것들이라 여기서 정리한다 — 자율성 기본값 같은 PoC 의존 값은 여전히 미정으로 남는다.
백엔드는 단일 서비스가 아니라 **두 개로 나눈다** — 코어(인증·메시지·DB)는 Go, AI 파이프라인은
Python. 순수 성능/동시성만 보면 이 프로젝트 규모(소규모 지인 네트워크 베타)에서 Python
비동기(FastAPI/asyncio)로도 충분하다 — 메신저 릴레이는 CPU-bound가 아니라 I/O-bound라 GIL이
병목이 되지 않고, 실제 지연은 백엔드 언어가 아니라 Gemini API 왕복 시간이 좌우한다. 그럼에도
Go를 코어에 쓰기로 한 건 향후 스케일 대비 선제적 판단이며, `poc/tone-corpus/`의 이미 검증된
AI 파이프라인(generate_draft·escalation_filter·retrieve_style)을 다시 짜지 않기 위해 AI 쪽만
Python으로 남긴다.
| 항목 | 결정 | 근거 |
|---|---|---|
| 클라이언트 | Flutter (Dart), **v1은 Android 빌드만** | Q7이 안드로이드 우선을 확정했지만 네이티브를 강제하진 않음. 채팅 UI(이미 클릭 프로토타입으로 검증된 디자인)를 핫리로드로 빠르게 만들 수 있어 v1 개발 속도에 유리. v2 OS 레이어의 알림 접근 권한(NotificationListenerService)은 platform channel로 네이티브 Android 모듈을 붙여 해결 — 클라이언트 전체를 네이티브로 갈 필요는 없음. iOS는 개발 환경이 Windows라 지금은 빌드 자체가 안 됨 (§6 참고) — Flutter라 나중에 Mac 환경이 생기면 UI 재작성 없이 iOS 빌드만 추가하면 됨 |
| 백엔드 — 코어 서비스 | Go (Gin/Echo + `gorilla/websocket`) | 인증, 메시지 릴레이, DB 접근. 동시성·성능 이점, 향후 스케일 대비. 이 프로젝트 규모에선 Python으로도 충분했지만 선제적으로 Go 선택 |
| 백엔드 — AI 서비스 | Python (FastAPI) | `generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 그대로 감싸는 내부 API. 이미 실행 검증까지 끝난 코드를 다시 짜지 않기 위함 |
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 |
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에
남아있는 PoC 의존 항목이다 — 여기서 같이 정하지 않는다.

View File

@ -0,0 +1,53 @@
# 유저 인터뷰 가이드 — Q3 자율성 수용성 (분신 사용자 관점)
`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 분신을 얼마나 신뢰하는지를 본다.
이 문서는 반대쪽 — **분신을 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다.
`decision-log.md` Q3("자율성 몇 단계까지 출시?")를 "L0~L2만"으로 잠정 결정했는데, 이게 실제
사용자들의 위임 의향과 맞는지 확인하는 것이 목적이다. 두 인터뷰는 서로 대체하지 않는다 —
분신이 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가
틀린 것이다.
## 대상 및 규모
- 메신저 응답 압박을 자주 느끀다고 스스로 말하는 개인 5~10명 (`vision.md` 타깃과 동일)
- 기존 AI 비서(챗GPT 등)를 이미 써본 사람과 안 써본 사람을 섞어서 섭외 — 위임 거부감 차이가
있는지 비교 가능
## 스크리닝 질문 (섭외 단계)
1. 최근 한 달 안에 "답장을 미뤄서 미안했던" 경험이 있나요?
2. 메신저 대화 중 몇 %가 "꼭 내가 직접 안 해도 되는" 가벼운 대화라고 느끼나요? (대략 %)
## 본 인터뷰 질문지
### 1. 위임 의향 (핵심)
- 자는 동안 온 메시지에 AI가 "지금 자는 중"이라고 대신 답해준다면, 어떤 느낌일 것 같나요?
- 그 응답을 보내기 전에 매번 확인하고 싶나요(L1), 아니면 특정 주제는 확인 없이 바로 보내도
괜찮을 것 같나요(L2)? 왜 그 기준인가요?
- "확인 없이 바로 보내도 되는 주제"를 직접 골라본다면 뭐가 있을까요? (화이트리스트 감 잡기)
- 반대로, "절대 AI가 대신 답하면 안 되는" 상대나 주제가 있나요?
### 2. 신뢰의 조건
- AI가 대신 답한 걸 나중에 알게 됐을 때, 뭘 보면 "그래도 괜찮았다"고 느낄 것 같나요?
(사후 알림, 되돌리기, 뱃지 등 중 무엇이 중요한지 자연스럽게 끌어내기)
- 위임했다가 되돌리고 싶었던 순간을 상상해본다면 언제일까요?
### 3. 관계별 차이
- 가까운 친구/가족과 회사·업무 상대, 위임하고 싶은 정도가 다른가요? 어떻게 다른가요?
### 4. 확장 의향 (v2 신호, 참고용 — v1 범위 확정에는 안 씀)
- 만약 AI가 약속 시간까지 상대와 조율해서 몇 개 후보만 골라준다면(L4), 쓰고 싶을 것 같나요?
아니면 그건 좀 부담스러운가요?
## 결과 정리 방법
- 답변을 "위임 희망 주제/상대" 목록으로 정리 → `PRD.md` §3.1 화이트리스트 주제 설계에 반영
- L0/L1/L2 중 실제 선호 분포를 확인 → 만약 다수가 L1도 부담스러워한다면(L0만 원함),
`decision-log.md` Q3을 재검토해야 함 — 이 경우 즉시 팀에 공유
- 절대 위임 불가 상대/주제 목록 → `PRD.md` §3.1 에스컬레이션 규칙, `risk-log.md`에 반영
## PoC#3과 함께 진행할 때
같은 참가자를 양쪽 다 인터뷰해도 된다. 순서는 **이 인터뷰(본인 관점) → `poc-materials.md`
역할극(상대방 관점)** 순으로 진행하는 걸 권장한다 — 먼저 "내가 위임하고 싶은 정도"를 스스로
답하게 한 뒤에 "상대방 입장"을 시켜보면, 두 관점의 온도차 자체가 흥미로운 신호가 된다.

41
docs/vision.md Normal file
View File

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

45
mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
mobile/.metadata Normal file
View File

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d7b523b356d15fb81e7d340bbe52b47f93937323"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
- platform: android
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

35
mobile/README.md Normal file
View File

@ -0,0 +1,35 @@
# 분신 mobile (Flutter)
Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다
(`docs/tech-design.md` §8).
## 지금 있는 것
- 초대 코드 가입 (`POST /auth/signup`)
- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음)
- 채팅 전송 + WebSocket 수신
- 분신 초안 요청 / L1 승인 발송
- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기
- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI
## 실행
```bash
# core-backend + ai-service 가 떠 있어야 실제 호출이 된다
cd mobile
flutter pub get
# Android 에뮬레이터 → 호스트의 core-backend(기본 8080)
flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
# 실기기/로컬 네트워크
flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080
```
## 아직 없는 것
- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요)
- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- drift + SQLCipher 온디바이스 저장
- 푸시 알림
- iOS 빌드 (v1 범위 밖)

View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
mobile/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.bunsin.bunsin_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.bunsin.bunsin_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="분신"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.bunsin.bunsin_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,21 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View File

@ -0,0 +1,25 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

20
mobile/lib/config.dart Normal file
View File

@ -0,0 +1,20 @@
/// Runtime config for talking to `core-backend/`.
///
/// Override at run time with:
/// `flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080`
class AppConfig {
static const coreApiBase = String.fromEnvironment(
'CORE_API_BASE',
defaultValue: 'http://10.0.2.2:8080', // Android emulator host localhost
);
static String wsBase() {
final uri = Uri.parse(coreApiBase);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return Uri(
scheme: scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
}
}

42
mobile/lib/main.dart Normal file
View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/conversation_list_screen.dart';
import 'screens/signup_screen.dart';
import 'state/session_state.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final session = SessionState();
await session.restore();
runApp(BunsinApp(session: session));
}
class BunsinApp extends StatelessWidget {
const BunsinApp({super.key, required this.session});
final SessionState session;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: session,
child: MaterialApp(
title: '분신',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1F6F5B),
brightness: Brightness.light,
),
useMaterial3: true,
),
home: Consumer<SessionState>(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();
return const ConversationListScreen();
},
),
),
);
}
}

View File

@ -0,0 +1,98 @@
enum SenderMode { human, twin }
// Product autonomy ladder names (PRD) keep L0/L1/L2 as identifiers.
// ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 }
class User {
User({required this.id, required this.displayName, required this.inviteCode});
final int id;
final String displayName;
final String inviteCode;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? '',
inviteCode: json['invite_code'] as String? ?? json['inviteCode'] as String? ?? '',
);
}
class TwinSettings {
TwinSettings({required this.autonomyLevel});
final AutonomyLevel autonomyLevel;
factory TwinSettings.fromJson(Map<String, dynamic> json) {
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
return TwinSettings(
autonomyLevel: AutonomyLevel.values.firstWhere(
(e) => e.name == raw,
orElse: () => AutonomyLevel.L0,
),
);
}
}
class ChatMessage {
ChatMessage({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderMode,
required this.text,
required this.retracted,
required this.createdAt,
});
final int id;
final int conversationId;
final int senderId;
final SenderMode senderMode;
final String text;
final bool retracted;
final DateTime createdAt;
bool get isTwin => senderMode == SenderMode.twin;
factory ChatMessage.fromJson(Map<String, dynamic> json) {
final mode = (json['sender_mode'] as String? ?? 'human').toLowerCase();
return ChatMessage(
id: json['id'] as int,
conversationId: json['conversation_id'] as int? ?? json['conversationId'] as int? ?? 0,
senderId: json['sender_id'] as int? ?? json['senderId'] as int? ?? 0,
senderMode: mode == 'twin' ? SenderMode.twin : SenderMode.human,
text: json['text'] as String? ?? '',
retracted: json['retracted'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
);
}
}
class WhitelistRule {
WhitelistRule({required this.id, required this.topicKeyword, this.contactId});
final int id;
final String topicKeyword;
final int? contactId;
factory WhitelistRule.fromJson(Map<String, dynamic> json) => WhitelistRule(
id: json['id'] as int,
topicKeyword: json['topic_keyword'] as String? ?? '',
contactId: json['contact_id'] as int?,
);
}
class DraftResult {
DraftResult({required this.status, required this.text});
final String status; // ok | escalate | no_key
final String text;
factory DraftResult.fromJson(Map<String, dynamic> json) => DraftResult(
status: json['status'] as String? ?? 'ok',
text: json['text'] as String? ?? '',
);
bool get isEscalate => status == 'escalate';
}

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
class AutonomySettingsScreen extends StatefulWidget {
const AutonomySettingsScreen({super.key});
@override
State<AutonomySettingsScreen> createState() => _AutonomySettingsScreenState();
}
class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
final _keyword = TextEditingController();
List<WhitelistRule> _rules = [];
String? _error;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
if (session.user == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final rules = await session.api.listWhitelist(session.user!.id);
setState(() => _rules = rules);
} on ApiException catch (e) {
setState(() => _error = '목록 로드 실패 (${e.statusCode})');
} finally {
setState(() => _loading = false);
}
}
@override
void dispose() {
_keyword.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
return Scaffold(
appBar: AppBar(title: const Text('자율성 설정')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SegmentedButton<AutonomyLevel>(
segments: const [
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
ButtonSegment(value: AutonomyLevel.L1, label: Text('L1'), tooltip: '승인 후 발송'),
ButtonSegment(value: AutonomyLevel.L2, label: Text('L2'), tooltip: '화이트리스트 자동'),
],
selected: {session.autonomyLevel},
onSelectionChanged: (s) => session.setAutonomy(s.first),
),
const SizedBox(height: 8),
Text(
'기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 32),
Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _keyword,
decoration: const InputDecoration(
labelText: '주제 키워드',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
final text = _keyword.text.trim();
if (text.isEmpty || session.user == null) return;
try {
final rule = await session.api.addWhitelist(session.user!.id, text);
setState(() {
_rules = [..._rules, rule];
_keyword.clear();
});
} on ApiException catch (e) {
setState(() => _error = '추가 실패 (${e.statusCode})');
}
},
child: const Text('추가'),
),
],
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 12),
if (_loading)
const Center(child: CircularProgressIndicator())
else
..._rules.map(
(r) => ListTile(
title: Text(r.topicKeyword),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () async {
if (session.user == null) return;
await session.api.deleteWhitelist(session.user!.id, r.id);
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,252 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../services/ws_client.dart';
import '../state/session_state.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.conversationId});
final int conversationId;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final _input = TextEditingController();
final _messages = <ChatMessage>[];
ConversationSocket? _socket;
StreamSubscription? _sub;
String? _banner;
DraftResult? _pendingDraft;
bool _busy = false;
@override
void initState() {
super.initState();
_socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent);
}
void _onEvent(Map<String, dynamic> event) {
final retractionId = _socket!.parseRetractionId(event);
if (retractionId != null) {
setState(() {
final i = _messages.indexWhere((m) => m.id == retractionId);
if (i >= 0) {
final m = _messages[i];
_messages[i] = ChatMessage(
id: m.id,
conversationId: m.conversationId,
senderId: m.senderId,
senderMode: m.senderMode,
text: m.text,
retracted: true,
createdAt: m.createdAt,
);
}
});
return;
}
final msg = _socket!.parseMessageEvent(event);
if (msg == null) return;
if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg));
}
@override
void dispose() {
_sub?.cancel();
_socket?.dispose();
_input.dispose();
super.dispose();
}
Future<void> _sendHuman() async {
final session = context.read<SessionState>();
final text = _input.text.trim();
if (text.isEmpty || session.user == null) return;
setState(() => _busy = true);
try {
final msg = await session.api.sendMessage(
conversationId: widget.conversationId,
senderId: session.user!.id,
text: text,
);
_input.clear();
if (!_messages.any((m) => m.id == msg.id)) {
setState(() => _messages.add(msg));
}
} on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally {
setState(() => _busy = false);
}
}
Future<void> _requestDraft() async {
final session = context.read<SessionState>();
setState(() {
_busy = true;
_banner = null;
_pendingDraft = null;
});
try {
final contextLines = _messages
.where((m) => !m.retracted)
.map((m) => '${m.isTwin ? "분신" : "상대"}: ${m.text}')
.toList();
if (_input.text.trim().isNotEmpty) {
contextLines.add('상대: ${_input.text.trim()}');
}
final draft = await session.api.requestDraft(
conversationId: widget.conversationId,
contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines,
styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'],
);
setState(() {
_pendingDraft = draft;
if (draft.isEscalate) {
_banner = '에스컬레이션: ${draft.text}';
}
});
} on ApiException catch (e) {
setState(() => _banner = '초안 실패 (${e.statusCode}): ${e.body}');
} finally {
setState(() => _busy = false);
}
}
Future<void> _sendTwinApproved() async {
final session = context.read<SessionState>();
final draft = _pendingDraft;
if (draft == null || draft.isEscalate || session.user == null) return;
setState(() => _busy = true);
try {
final msg = await session.api.sendMessage(
conversationId: widget.conversationId,
senderId: session.user!.id,
text: draft.text,
senderMode: SenderMode.twin,
approved: true,
);
setState(() {
_pendingDraft = null;
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
});
} on ApiException catch (e) {
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
} finally {
setState(() => _busy = false);
}
}
Future<void> _veto() async {
final session = context.read<SessionState>();
try {
await session.api.vetoConversation(widget.conversationId);
setState(() => _banner = '거부권 적용: 이 대화방에서 분신 자동응대가 중단됩니다.');
} on ApiException catch (e) {
setState(() => _banner = '거부권 실패 (${e.statusCode})');
}
}
Future<void> _retract(ChatMessage message) async {
final session = context.read<SessionState>();
try {
await session.api.retractMessage(message.id);
} on ApiException catch (e) {
setState(() => _banner = '되돌리기 실패 (${e.statusCode})');
}
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final me = session.user?.id;
return Scaffold(
appBar: AppBar(
title: Text('대화방 #${widget.conversationId}'),
actions: [
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
],
),
body: Column(
children: [
if (_banner != null)
MaterialBanner(
content: Text(_banner!),
actions: [
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
],
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return MessageBubble(
message: m,
isMine: me != null && m.senderId == me,
onRetract: m.isTwin ? () => _retract(m) : null,
);
},
),
),
if (_pendingDraft != null && !_pendingDraft!.isEscalate)
Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
title: const Text('분신 초안 (L1 승인)'),
subtitle: Text(_pendingDraft!.text),
trailing: FilledButton(
onPressed: _busy ? null : _sendTwinApproved,
child: const Text('보내기'),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
hintText: '메시지',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: '초안 요청',
onPressed: _busy ? null : _requestDraft,
icon: const Icon(Icons.auto_awesome),
),
const SizedBox(width: 4),
IconButton.filled(
tooltip: '보내기',
onPressed: _busy ? null : _sendHuman,
icon: const Icon(Icons.send),
),
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
import 'autonomy_settings_screen.dart';
import 'chat_screen.dart';
/// v1 scaffold: conversation list is local until list API exists on core-backend.
/// Enter a conversation id to open a room (matches current Go API shape).
class ConversationListScreen extends StatefulWidget {
const ConversationListScreen({super.key});
@override
State<ConversationListScreen> createState() => _ConversationListScreenState();
}
class _ConversationListScreenState extends State<ConversationListScreen> {
final _convId = TextEditingController(text: '1');
final _rooms = <int>{1};
@override
void dispose() {
_convId.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final rooms = _rooms.toList()..sort();
return Scaffold(
appBar: AppBar(
title: Text('분신 · ${session.user?.displayName ?? ''}'),
actions: [
IconButton(
tooltip: '자율성 설정',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()),
);
},
icon: const Icon(Icons.tune),
),
],
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _convId,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '대화방 ID',
border: OutlineInputBorder(),
helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
final id = int.tryParse(_convId.text.trim());
if (id == null) return;
setState(() => _rooms.add(id));
},
child: const Text('추가'),
),
],
),
),
for (final id in rooms)
ListTile(
leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)),
title: Text('대화방 #$id'),
subtitle: const Text('탭해서 입장'),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)),
);
},
),
],
),
);
}
}

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _invite = TextEditingController();
final _name = TextEditingController();
@override
void dispose() {
_invite.dispose();
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
Text(
'초대 코드로 클로즈드 베타에 참여합니다.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
TextField(
controller: _invite,
decoration: const InputDecoration(
labelText: '초대 코드',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: const InputDecoration(
labelText: '표시 이름',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
),
if (session.error != null) ...[
const SizedBox(height: 12),
Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const Spacer(),
FilledButton(
onPressed: session.loading
? null
: () => session.signup(_invite.text.trim(), _name.text.trim()),
child: session.loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('시작하기'),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,125 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../models/models.dart';
class ApiException implements Exception {
ApiException(this.statusCode, this.body);
final int statusCode;
final String body;
@override
String toString() => 'ApiException($statusCode): $body';
}
/// Thin REST client for `core-backend/` endpoints used by the v1 scaffold.
class ApiClient {
ApiClient({http.Client? httpClient, String? baseUrl})
: _http = httpClient ?? http.Client(),
_base = baseUrl ?? AppConfig.coreApiBase;
final http.Client _http;
final String _base;
Uri _u(String path) => Uri.parse('$_base$path');
Future<Map<String, dynamic>> _json(
String method,
String path, {
Map<String, dynamic>? body,
}) async {
final req = http.Request(method, _u(path));
req.headers['Content-Type'] = 'application/json';
if (body != null) req.body = jsonEncode(body);
final streamed = await _http.send(req);
final res = await http.Response.fromStream(streamed);
if (res.statusCode >= 400) {
throw ApiException(res.statusCode, res.body);
}
if (res.body.isEmpty) return {};
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<List<dynamic>> _jsonList(String path) async {
final res = await _http.get(_u(path));
if (res.statusCode >= 400) {
throw ApiException(res.statusCode, res.body);
}
return jsonDecode(res.body) as List<dynamic>;
}
Future<User> signup({required String inviteCode, required String displayName}) async {
final json = await _json('POST', '/auth/signup', body: {
'invite_code': inviteCode,
'display_name': displayName,
});
// core-backend returns {id, display_name} only keep the invite we sent.
return User(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? displayName,
inviteCode: inviteCode,
);
}
Future<ChatMessage> sendMessage({
required int conversationId,
required int senderId,
required String text,
SenderMode senderMode = SenderMode.human,
bool approved = false,
}) async {
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
'sender_id': senderId,
'text': text,
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
if (approved) 'approved': true,
});
return ChatMessage.fromJson(json);
}
Future<DraftResult> requestDraft({
required int conversationId,
required List<String> contextLines,
List<String>? styleExamples,
}) async {
final body = <String, dynamic>{
'context_lines': contextLines,
if (styleExamples != null) 'style_examples': styleExamples,
};
final json = await _json('POST', '/conversations/$conversationId/draft', body: body);
return DraftResult.fromJson(json);
}
Future<void> vetoConversation(int conversationId) async {
await _json('POST', '/conversations/$conversationId/veto');
}
Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract');
}
Future<TwinSettings> patchTwinSettings(int userId, AutonomyLevel level) async {
final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
'autonomy_level': level.name,
});
return TwinSettings.fromJson(json);
}
Future<List<WhitelistRule>> listWhitelist(int userId) async {
final list = await _jsonList('/users/$userId/whitelist-rules');
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
}
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
'topic_keyword': topicKeyword,
});
return WhitelistRule.fromJson(json);
}
Future<void> deleteWhitelist(int userId, int ruleId) async {
await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId');
}
}

View File

@ -0,0 +1,53 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../config.dart';
import '../models/models.dart';
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
class ConversationSocket {
ConversationSocket(this.conversationId);
final int conversationId;
WebSocketChannel? _channel;
final _controller = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get events => _controller.stream;
void connect() {
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
(raw) {
if (raw is! String) return;
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_controller.add(decoded);
}
},
onError: _controller.addError,
onDone: () {},
);
}
ChatMessage? parseMessageEvent(Map<String, dynamic> event) {
final type = event['type'] as String?;
if (type != null && type != 'message') return null;
// Backend may wrap payload or send the message object directly.
final payload = event['message'] as Map<String, dynamic>? ?? event;
if (payload['id'] == null || payload['text'] == null) return null;
return ChatMessage.fromJson(payload);
}
int? parseRetractionId(Map<String, dynamic> event) {
if (event['type'] != 'retraction') return null;
return event['id'] as int?;
}
Future<void> dispose() async {
await _channel?.sink.close();
await _controller.close();
}
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/models.dart';
import '../services/api_client.dart';
class SessionState extends ChangeNotifier {
SessionState({ApiClient? api}) : _api = api ?? ApiClient();
final ApiClient _api;
User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
String? error;
bool loading = false;
static const _kUserId = 'user_id';
static const _kDisplayName = 'display_name';
static const _kInvite = 'invite_code';
Future<void> restore() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt(_kUserId);
final name = prefs.getString(_kDisplayName);
final invite = prefs.getString(_kInvite);
if (id != null && name != null && invite != null) {
user = User(id: id, displayName: name, inviteCode: invite);
notifyListeners();
}
}
Future<void> signup(String inviteCode, String displayName) async {
loading = true;
error = null;
notifyListeners();
try {
final created = await _api.signup(inviteCode: inviteCode, displayName: displayName);
user = created;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kUserId, created.id);
await prefs.setString(_kDisplayName, created.displayName);
await prefs.setString(_kInvite, created.inviteCode);
} on ApiException catch (e) {
error = '가입 실패 (${e.statusCode}): ${e.body}';
} catch (e) {
error = e.toString();
} finally {
loading = false;
notifyListeners();
}
}
Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return;
try {
final settings = await _api.patchTwinSettings(user!.id, level);
autonomyLevel = settings.autonomyLevel;
notifyListeners();
} on ApiException catch (e) {
error = '자율성 변경 실패 (${e.statusCode})';
notifyListeners();
}
}
ApiClient get api => _api;
}

View File

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
/// Twin messages use a dashed border + badge (PRD §3.1 ).
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
required this.message,
required this.isMine,
this.onRetract,
});
final ChatMessage message;
final bool isMine;
final VoidCallback? onRetract;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final twin = message.isTwin;
final bg = isMine
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest;
final bubble = Container(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(14),
border: twin
? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (twin)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'분신',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.tertiary,
fontWeight: FontWeight.w700,
),
),
),
Text(
message.retracted ? '(되돌린 메시지)' : message.text,
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
color: message.retracted ? theme.disabledColor : null,
),
),
if (twin && isMine && !message.retracted && onRetract != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onRetract,
child: const Text('되돌리기'),
),
),
],
),
);
// Dashed look for twin: overlay a custom painter border when twin.
if (!twin) {
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: bubble,
);
}
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: CustomPaint(
painter: _DashedRRectPainter(color: theme.colorScheme.tertiary),
child: bubble,
),
);
}
}
class _DashedRRectPainter extends CustomPainter {
_DashedRRectPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
const Radius.circular(14),
);
final path = Path()..addRRect(rrect);
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
canvas.drawPath(dashed, paint);
}
Path _dashPath(Path source, {required double dashLength, required double gapLength}) {
final metrics = source.computeMetrics();
final out = Path();
for (final metric in metrics) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + dashLength;
out.addPath(metric.extractPath(distance, next.clamp(0, metric.length)), Offset.zero);
distance = next + gapLength;
}
}
return out;
}
@override
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
}

410
mobile/pubspec.lock Normal file
View File

@ -0,0 +1,410 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
url: "https://pub.dev"
source: hosted
version: "2.4.13"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0"

93
mobile/pubspec.yaml Normal file
View File

@ -0,0 +1,93 @@
name: bunsin_mobile
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.8.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
http: ^1.6.0
web_socket_channel: ^3.0.3
provider: ^6.1.5+1
shared_preferences: ^2.5.3
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

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

142
poc/tone-corpus/README.md Normal file
View File

@ -0,0 +1,142 @@
# PoC #1 기반 코퍼스 — AI-Hub 한국어 SNS 멀티턴 대화
`docs/poc-plan.md`의 PoC #1(온디바이스 말투 학습)에서, 개인화 레이어를 얹기 전 **기반 톤 생성
모델**을 학습/평가할 코퍼스로 AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋을 사용한다. 이 데이터는
특정 개인의 말투가 아니라 익명 화자쌍의 일반 대화이므로, PoC #1의 "내 말투 같다" 개인화 검증
자체를 대체하지는 않는다 — 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다
(`docs/poc-materials.md` §1 참고).
## 사용법
```bash
python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output <출력 디렉토리> [--qa]
```
- `--input`: AI-Hub에서 내려받은 `TS_*.zip`/`TL_*.zip`(학습)·`VS_*.zip`/`VL_*.zip`(검증) 원본 파일이
있는 디렉토리. 파일명에 `VL_`/`TL_`이 포함된 zip(라벨링 JSON)만 읽는다 — 여기에 발화 텍스트와
화행(speech_act)·슬롯 라벨이 모두 있어서 `VS_`/`TS_`(원천 CSV)는 출력에 필요 없다.
- `--output`: `train.jsonl`, `val.jsonl`, `stats.json`을 쓸 디렉토리.
- `--qa`: 원천 CSV의 대화ID와 라벨 JSON의 대화ID를 교차검증해서 커버리지 차이를 출력.
## 실행 결과 (2026-07-29, 이 세션에서 실제로 돌려본 값)
- 총 142,575건 (학습 122,951 / 검증 19,624), 평균 발화 16.5턴/대화
- 주제 9종, 정치(3,242건)·경제및사회(4,257건)가 다른 주제(1.7만~2.2만건)보다 훨씬 적음 — 주제별
균형이 필요하면 샘플링 시 감안할 것
- 화자 연령/성별 필드 노이즈 81건은 정제 과정에서 `null`로 치환 (버리지 않고 필드만 비움)
- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 34,030건이 더 있었음 (라벨 없이 텍스트만).
`build_unlabeled_corpus.py`로 그 34,030건만 뽑아 `unlabeled.jsonl`(63MB)로 정리함 — 화행/슬롯
없이 화자·발화 텍스트만 있어서 순수 언어모델링(다음 문장 예측 등)용으로만 쓸 것
```bash
python3 build_unlabeled_corpus.py --input <원본 zip 디렉토리> --output <출력 디렉토리>
```
## 응답 초안 생성기 (`generate_draft.py`)
PoC #1의 "응답 초안 생성" 단계를 채우는 프로토타입. 말투 예시(그 사람이 쓴 문장 몇 개) +
최근 대화 맥락을 받아서, `tech-design.md` §2의 서버 LLM 폴백 경로처럼 LLM 호출로 답장 초안
하나를 만든다. 확정성 있는 내용(금전·약속·감정 이슈)은 `[ESCALATE]`를 출력하도록 시스템
프롬프트에 못박아뒀다 — `AGENTS.md`의 절대 안전선을 프롬프트 레벨에서도 지키기 위함.
```bash
pip install -r requirements.txt
export GEMINI_API_KEY=...
python3 generate_draft.py --style style_examples.txt --context context.txt
```
Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은
`gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다.
**Cursor 환경에서 `.env`에 실제 키를 넣고 라이브 호출까지 성공 확인됨** (2026-07-29) — 스타일
예시(반말·ㅋ톤)에 맞는 짧은 캐주얼 답장 초안이 그대로 나왔다. 이 Claude Code 세션에는 키가 없어서,
동일한 검증은 이 세션에서는 프롬프트 구성 확인 + 사람이 직접 쓴 초안 대조로 대체했다 (아래 참고).
### `GEMINI_API_KEY`는 어디에 설정하나
- **권장**: 저장소 루트 `.env``GEMINI_API_KEY=...`를 넣는다 (템플릿은 `.env.example`).
`generate_draft.py`가 실행 시 이 파일을 읽는다. `.env``.gitignore`로 커밋되지 않는다.
- **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서
발급한 키를 그대로 쓰면 된다.
- 대화창에 직접 키 값을 붙여넣는 건 대화 기록에 그대로 남으므로 권장하지 않는다 — 위 두 방법 중
하나로, 사용량 제한을 걸어둔 키를 쓸 것.
**샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만
스타일 예시로 사용):
- 실제 답장: `ㅋㅋ 내가 지금 말한 거 고교학점제 홈피에 다 있는 내용이니까 궁금하면 가서 더 찾아봐랑!ㅋ`
- 생성 초안(수동): `ㅋㅋㅋ 뭘 유식까지야, 그냥 관심있어서 좀 찾아본거임 너도 궁금하면 고교학점제 사이트 가서 찾아봐~ㅋ`
- 둘 다 "ㅋㅋ로 시작 → 정보 제공자 역할 수용 → '찾아봐'식 권유형 마무리 → ㅋ로 끝" 패턴이 겹침.
다만 이건 익명 화자의 일반 대화 스타일 재현 여부를 본 것일 뿐, 실제 개인화("이 사람 말투 같다")
검증은 아니다 — 그건 `poc-materials.md`의 실제 참가자 데이터로만 확인 가능하다.
## 에스컬레이션 판정기 (`escalation_filter.py`)
`generate_draft.py`가 LLM을 호출하기 **전에** 먼저 통과해야 하는 규칙 기반 하드 게이트.
금전·약속 확정·감정적으로 무거운 주제면 LLM을 부르지도 않고 즉시 `[ESCALATE:사유]`를 반환한다 —
`AGENTS.md`의 절대 안전선을 "모델이 알아서 잘 판단하겠지"에 맡기지 않고 코드 레벨에서 강제한다.
LLM 시스템 프롬프트의 `[ESCALATE]` 지시는 이 규칙이 놓친 케이스를 위한 2차 방어선으로 남겨둔다.
```bash
python3 escalation_filter.py --text "계좌로 3만원만 보내줘"
python3 escalation_filter.py --selftest # 내장 테스트 케이스 10개, 10/10 통과 확인됨
```
**검증셋(5,000개 대화, 82,305개 발화)에 돌려본 결과**: 767건(0.93%) 트리거 — 금전 523 / 감정 238 /
약속확정 6. 트리거된 걸 직접 살펴보니 대부분 "월세 85만원", "벌금 500만원" 같은 **일반적인 시사/경제
얘기에서 나온 액수**였다 — 실제 서비스에서는 1:1 사적 대화라 이런 팩트성 언급보다 진짜 정산·확정
요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용
로그로 계속 튜닝한다 (`docs/tech-design.md` §3, `docs/risk-log.md` 참고).
## 블라인드 평가 자동화 (`blind_eval.py`)
`docs/poc-plan.md`의 블라인드 평가를 검증셋 전체에 대해 자동으로 돌려보는 배치 스크립트. 각
대화의 마지막 발화를 가리고, 그 화자의 앞선 발화를 말투 예시로 써서 `generate_draft.draft_reply`
초안을 생성한 뒤 실제 발화와 나란히 JSONL로 남긴다.
```bash
python3 blind_eval.py --corpus <val.jsonl 경로> --n 30 --output eval_report.jsonl
```
**주의 — 이건 진짜 PoC #1이 아니다.** 익명 화자쌍의 일반 대화라 "내 말투 같다"를 판단할 수 없다.
자동 계산하는 `length_ratio`(길이 비율), `formal_match`(존댓말/반말 일치 여부)도 사람이 5점 척도로
평가하는 진짜 지표의 대체재가 아니라, **대량으로 돌려서 파이프라인 자체가 말이 되는 응답을
내는지**(요청한 형식으로 나오는지, 에스컬레이션이 과하게/적게 걸리는지) 보는 사전 체크용이다.
진짜 개인화 평가는 `poc-materials.md` §1로 실제 참가자 데이터를 모은 뒤에만 가능하다.
`--n 5`로 실제 검증셋에 돌려 확인함 — 샘플링, 컨텍스트/스타일 예시 구성, JSONL 출력 전부 정상
동작 (API 키 없이 `no_key` 상태로 5/5 처리됨, 리포트 레코드 형식도 의도대로 나옴).
## 말투 검색기 (`retrieve_style.py`)
`docs/tech-design.md` §2-1에서 설계한 "가벼운 키워드/최근성 기반 검색"의 실제 구현. 그 사람의
과거 발화 전체(오래된 것부터 한 줄에 하나)와 지금 답장해야 할 메시지를 주면, 키워드 겹침(자카드
유사도) + 최근성 가중치로 점수를 매겨 가장 비슷한 발화 상위 k개를 돌려준다. 임베딩은 쓰지 않는다 —
"지금부터 임베딩 인프라를 먼저 만들지 않는다"는 설계 원칙을 그대로 따름.
```bash
python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6
```
`generate_draft.py`에 그대로 연결되어 있다 — `--style` 대신 `--history`를 주면 매번 스타일 예시를
손으로 고르지 않고 자동으로 검색해서 쓴다:
```bash
python3 generate_draft.py --history history.txt --context context.txt
```
**버그를 하나 잡음**: 처음엔 `overlap + RECENCY_WEIGHT * recency`로 점수를 더했는데, 실제로
돌려보니 짧은 한국어 문장은 조사가 붙어서("핀란드" vs "핀란드는") 토큰이 잘 안 겹쳐 자카드
유사도가 거의 항상 최근성 항보다 작았다 — 결과적으로 검색이 사실상 "그냥 최신 메시지 K개"가
되는 문제였다. `(overlap, recency)` 튜플로 정렬해서 **키워드가 실제로 겹치는 게 항상 먼저 오고,
동점일 때만 최근성으로 갈리게** 고쳤다. 검증: `--query "핀란드는 교육이 좋대"`로 돌리니 겹치는
토큰이 있는 과거 발화가 1위로 올라오고, 나머지는 최근 순으로 정렬됨을 확인.
## 반드시 지킬 것
- **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상
제3자 재배포가 제한되고, 용량도 수백MB~1GB라 저장소에 맞지 않는다. 저장소 루트의 `.gitignore`
`poc/tone-corpus/data/`가 등록되어 있으니 원본·출력은 그 아래에 두고 작업할 것.
- 이 저장소는 기획 문서 전용이라, 이 스크립트도 "PoC 도구"로만 취급한다 — 여기서 앱 코드/프레임워크로
확장하지 않는다 (`AGENTS.md` 참고).
- 실제 모델 학습에 쓰기 전에, 이 데이터가 세션이 아닌 영구 저장소(본인 로컬/스토리지)로 옮겨졌는지
확인할 것 — 클라우드 세션은 종료되면 임시 파일이 사라진다.

View File

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Batch version of PoC #1's blind evaluation (`docs/poc-plan.md`): hold out
each dialogue's last utterance, draft a reply from the rest via
`generate_draft.draft_reply`, and report it next to the real one.
This is still the general AI-Hub corpus, not a real person's messages --
so it can only sanity-check "does the pipeline produce plausible Korean
SNS replies," not "does this feel like me." The real PoC #1 blind eval
(human raters, 5-point scale) still needs actual participant data per
`docs/poc-materials.md` §1. Treat this as a pre-check, not a substitute.
Usage:
python3 blind_eval.py --corpus <path to val.jsonl or train.jsonl> --n 30 \
--output eval_report.jsonl
Without GEMINI_API_KEY set, each sample's status will be "no_key" and the
report just records what would have been sent -- useful for validating the
sampling logic itself before spending API calls.
"""
import argparse
import json
import random
import re
import sys
from generate_draft import draft_reply, load_dotenv_if_present
MIN_TURNS = 6
MIN_STYLE_EXAMPLES = 3
FORMAL_ENDING = re.compile(r"(요|니다)[.!?~ㅋㅎㅠㅜ]*$")
def build_sample(dialogue):
utts = dialogue.get("utterances", [])
if len(utts) < MIN_TURNS:
return None
held_speaker = utts[-1]["speaker"]
real_reply = utts[-1]["text"]
context_lines = []
style_examples = []
for u in utts[:-1]:
label = "" if u["speaker"] == held_speaker else "상대"
context_lines.append(f"{label}: {u['text']}")
if u["speaker"] == held_speaker:
style_examples.append(u["text"])
if len(style_examples) < MIN_STYLE_EXAMPLES:
return None
return {
"id": dialogue.get("id"),
"topic": dialogue.get("topic"),
"style_examples": style_examples,
"context_lines": context_lines,
"real_reply": real_reply,
}
def is_formal(text):
return bool(FORMAL_ENDING.search(text.strip()))
def evaluate(sample, model):
status, text = draft_reply(sample["style_examples"], sample["context_lines"], model=model)
record = {
"id": sample["id"],
"topic": sample["topic"],
"real_reply": sample["real_reply"],
"status": status,
}
if status == "ok":
record["generated_draft"] = text
record["length_ratio"] = round(len(text) / max(len(sample["real_reply"]), 1), 2)
record["formal_match"] = is_formal(text) == is_formal(sample["real_reply"])
elif status == "escalate":
record["escalation_reason"] = text
else:
record["prompt_preview"] = text
return record
def summarize(records):
total = len(records)
counts = {}
for r in records:
counts[r["status"]] = counts.get(r["status"], 0) + 1
ok = [r for r in records if r["status"] == "ok"]
summary = {"total": total, "status_counts": counts}
if ok:
summary["avg_length_ratio"] = round(sum(r["length_ratio"] for r in ok) / len(ok), 2)
summary["formal_match_rate"] = round(sum(r["formal_match"] for r in ok) / len(ok), 2)
return summary
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", required=True, help="val.jsonl 또는 train.jsonl 경로")
ap.add_argument("--n", type=int, default=30, help="평가할 대화 수")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--model", default="gemini-2.5-flash")
ap.add_argument("--output", required=True, help="결과를 쓸 JSONL 경로")
args = ap.parse_args()
load_dotenv_if_present()
with open(args.corpus, encoding="utf-8") as f:
dialogues = [json.loads(line) for line in f if line.strip()]
samples = [s for s in (build_sample(d) for d in dialogues) if s]
if not samples:
raise SystemExit(f"{MIN_TURNS}턴 이상, 화자 발화 {MIN_STYLE_EXAMPLES}개 이상인 대화가 없습니다.")
rng = random.Random(args.seed)
chosen = rng.sample(samples, min(args.n, len(samples)))
records = []
with open(args.output, "w", encoding="utf-8") as out:
for i, sample in enumerate(chosen, 1):
record = evaluate(sample, args.model)
records.append(record)
out.write(json.dumps(record, ensure_ascii=False) + "\n")
print(f"[{i}/{len(chosen)}] {record['id']} status={record['status']}", file=sys.stderr)
summary = summarize(records)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Pull the dialogues that exist in the AI-Hub raw source (VS_/TS_ CSV) but
were never labeled (VL_/TL_ JSON) -- 34,030 of them, per the --qa check in
prepare_dataset.py. No speech_act/slot labels are available for these, so
they're only useful for plain language-modeling, not the labeled fields
`generate_draft.py`'s prompt doesn't use anyway.
Usage:
python3 build_unlabeled_corpus.py --input <dir with the *.zip parts> --output <output dir>
"""
import argparse
import csv
import glob
import io
import json
import os
import zipfile
from collections import OrderedDict
VALID_SEX = {"남자", "여자"}
def is_valid_age(value):
return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0
def clean(value, kind):
if not value:
return None
if kind == "sex":
return value if value in VALID_SEX else None
return value if is_valid_age(value) else None
def labeled_ids(input_dir):
ids = set()
for path in glob.glob(os.path.join(input_dir, "*VL_*.zip")) + glob.glob(
os.path.join(input_dir, "*TL_*.zip")
):
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if name.endswith(".json"):
with z.open(name) as f:
ids.add(json.load(f)["info"]["id"])
return ids
def read_csv_dialogues(input_dir):
dialogues = OrderedDict()
csv_paths = sorted(
glob.glob(os.path.join(input_dir, "*VS_*.zip")) + glob.glob(os.path.join(input_dir, "*TS_*.zip"))
)
for path in csv_paths:
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if not name.endswith(".csv"):
continue
with z.open(name) as f:
text = io.TextIOWrapper(f, encoding="utf-8-sig")
current = None
for row in csv.DictReader(text):
if row.get("대화ID"):
did = row["대화ID"]
speakers = {}
for letter in ("A", "B", "C"):
sid = row.get(f"화자{letter} ID")
if not sid:
continue
speakers[letter] = {
"id": sid,
"sex": clean(row.get(f"화자{letter} 성별"), "sex"),
"age": clean(row.get(f"화자{letter} 연령대"), "age"),
}
current = {
"id": did,
"topic": row.get("주제"),
"keyword": row.get("키워드"),
"speakers": speakers,
"utterances": [],
}
dialogues[did] = current
if current is None:
continue
current["utterances"].append(
{"speaker": row.get("발화자", ""), "text": row.get("발화", "")}
)
return dialogues
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True)
ap.add_argument("--output", required=True)
args = ap.parse_args()
os.makedirs(args.output, exist_ok=True)
already_labeled = labeled_ids(args.input)
all_dialogues = read_csv_dialogues(args.input)
out_path = os.path.join(args.output, "unlabeled.jsonl")
kept = 0
with open(out_path, "w", encoding="utf-8") as out:
for did, d in all_dialogues.items():
if did in already_labeled:
continue
d["turns"] = len(d["utterances"])
out.write(json.dumps(d, ensure_ascii=False) + "\n")
kept += 1
print(f"원천 전체: {len(all_dialogues)}건 / 라벨링됨(제외): {len(already_labeled)}건 / 출력: {kept}건 -> {out_path}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Rule-based escalation gate -- the first step of the autonomy engine in
`docs/tech-design.md` §3. Money, appointment confirmation, and emotionally
heavy content always escalate to the human, at every autonomy level, with
no exception (`AGENTS.md` absolute safety invariants).
This runs BEFORE any LLM call. `generate_draft.py`'s own [ESCALATE]
instruction in its system prompt is a second line of defense for whatever
this misses, not a replacement for it -- a keyword miss must not be the
only thing standing between a user and an auto-sent money confirmation.
v1 is keyword/regex only, per tech-design.md §3: "100% 정확도를 목표하지
않는다 -- 애매하면 항상 에스컬레이션 쪽으로 fail-safe." Tune the pattern
lists against real false positive/negative rates once PoC data comes in.
Usage:
python3 escalation_filter.py --text "계좌로 3만원만 보내줘"
python3 escalation_filter.py --selftest
"""
import argparse
import re
from dataclasses import dataclass
MONEY_PATTERNS = [
r"\d[\d,]*\s*(원|만원|천원)",
r"(계좌|입금|송금|이체|환불|결제|대출|카드번호|계좌번호)",
]
APPOINTMENT_PATTERNS = [
r"(그럼|그러면).{0,10}(맞지|확정|콜)",
r"(약속|만나|보자).{0,10}(확정|잡자|정하자)",
r"\d{1,2}시.{0,10}(맞지|확정|괜찮|어때)",
]
EMOTIONAL_KEYWORDS = [
"힘들어", "힘들다", "슬퍼", "슬프다", "우울", "죽고싶", "죽고 싶",
"아파", "이별", "헤어졌", "헤어지자", "싸웠어", "화나", "짜증나", "속상",
]
_MONEY = [re.compile(p) for p in MONEY_PATTERNS]
_APPOINTMENT = [re.compile(p) for p in APPOINTMENT_PATTERNS]
@dataclass
class EscalationResult:
escalate: bool
reason: str = ""
def check(text):
for pat in _MONEY:
if pat.search(text):
return EscalationResult(True, "금전")
for pat in _APPOINTMENT:
if pat.search(text):
return EscalationResult(True, "약속 확정")
for kw in EMOTIONAL_KEYWORDS:
if kw in text:
return EscalationResult(True, "감정적으로 무거운 주제")
return EscalationResult(False)
SELFTEST_CASES = [
("계좌로 3만원만 보내줘", True),
("그 카페 계좌번호 좀 알려줄래", True),
("그럼 내일 3시 맞지?", True),
("약속 시간 확정하자, 언제가 좋아", True),
("나 요즘 너무 힘들어서 죽고싶다는 생각이 들어", True),
("우리 어제 왜 그렇게 싸웠어", True),
("오늘 저녁에 뭐 먹을래?", False),
("크라비 여행 가보고 싶어", False),
("고등학교 학점제가 뭔지 설명해줄 수 있어?", False),
("이 영화 재밌었어? 나도 보고싶다", False),
]
def selftest():
failed = 0
for text, expected in SELFTEST_CASES:
result = check(text)
ok = result.escalate == expected
failed += not ok
mark = "OK" if ok else "FAIL"
print(f"[{mark}] escalate={result.escalate} ({result.reason or '-'}) <- {text}")
total = len(SELFTEST_CASES)
print(f"\n{total - failed}/{total} 통과")
return failed == 0
def main():
ap = argparse.ArgumentParser()
group = ap.add_mutually_exclusive_group(required=True)
group.add_argument("--text", help="검사할 메시지 한 줄")
group.add_argument("--selftest", action="store_true", help="내장 테스트 케이스 실행")
args = ap.parse_args()
if args.selftest:
ok = selftest()
raise SystemExit(0 if ok else 1)
result = check(args.text)
print(f"escalate={result.escalate} reason={result.reason or '-'}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Generate a tone-matched reply draft -- the "응답 초안 생성기" that PoC #1
(`docs/poc-plan.md`) blind-evaluates against a person's real reply.
Takes a few example messages written by the target person (style exemplars)
plus the recent conversation, and asks an LLM to draft the next reply in
that voice. This is the server-fallback path described in
`docs/tech-design.md` §2 ("온디바이스 부족시 서버 LLM 호출, 최소 컨텍스트만
전송") -- a real on-device model would replace the API call later, but the
prompt contract (exemplars + context in, one draft out) stays the same.
Usage:
# Prefer repo-root .env (GEMINI_API_KEY=...), or:
export GEMINI_API_KEY=...
python3 generate_draft.py --style style_examples.txt --context context.txt
# or, to auto-pick exemplars from a person's message history instead of
# a hand-curated style file (retrieve_style.py, tech-design.md §2-1):
python3 generate_draft.py --history history.txt --context context.txt
style_examples.txt: one example message per line, written by the target person.
context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending
with the incoming message that needs a reply.
history.txt: the person's past messages, one per line, oldest first.
"""
import argparse
import os
import sys
from pathlib import Path
from escalation_filter import check as check_escalation
from retrieve_style import retrieve as retrieve_style_examples
def load_dotenv_if_present():
"""Load KEY=VALUE pairs from the nearest .env (repo root preferred)."""
if os.environ.get("GEMINI_API_KEY"):
return
here = Path(__file__).resolve()
candidates = [here.parent / ".env", here.parent.parent.parent / ".env", Path.cwd() / ".env"]
for env_path in candidates:
if not env_path.is_file():
continue
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("'").strip('"')
if key and key not in os.environ:
os.environ[key] = value
break
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
지켜야 :
- 답장 초안만 출력한다. 설명, 인사말, 따옴표를 덧붙이지 않는다.
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
def build_user_prompt(style_examples, context_lines):
examples = "\n".join(f"- {s}" for s in style_examples)
context = "\n".join(context_lines)
return f"""[말투 예시]\n{examples}\n\n[최근 대화]\n{context}\n\n위 대화의 마지막 메시지에 대한 답장 초안:"""
def last_incoming_text(context_lines):
"""The message needing a reply -- last line, minus its '상대: '/'나: ' prefix."""
if not context_lines:
return ""
last = context_lines[-1]
return last.split(": ", 1)[1] if ": " in last else last
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft.
"""
gate = check_escalation(last_incoming_text(context_lines))
if gate.escalate:
return "escalate", gate.reason
api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
return "no_key", build_user_prompt(style_examples, context_lines)
from google import genai # pip install google-genai
from google.genai import types
client = genai.Client(api_key=api_key)
resp = client.models.generate_content(
model=model,
contents=build_user_prompt(style_examples, context_lines),
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
)
return "ok", resp.text.strip()
def main():
ap = argparse.ArgumentParser()
style_group = ap.add_mutually_exclusive_group(required=True)
style_group.add_argument("--style", help="말투 예시 파일 (한 줄에 한 문장, 직접 큐레이션)")
style_group.add_argument("--history", help="과거 발화 전체 파일 (한 줄에 하나) -- 맥락과 비슷한 것을 자동 검색")
ap.add_argument("--k", type=int, default=6, help="--history 사용 시 검색해올 예시 개수")
ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)")
ap.add_argument("--model", default="gemini-2.5-flash")
args = ap.parse_args()
load_dotenv_if_present()
with open(args.context, encoding="utf-8") as f:
context_lines = [l.strip() for l in f if l.strip()]
if args.style:
with open(args.style, encoding="utf-8") as f:
style_examples = [l.strip() for l in f if l.strip()]
else:
with open(args.history, encoding="utf-8") as f:
history = [l.strip() for l in f if l.strip()]
style_examples = retrieve_style_examples(history, last_incoming_text(context_lines), k=args.k)
status, text = draft_reply(style_examples, context_lines, model=args.model)
if status == "escalate":
print(f"[ESCALATE:{text}] 이 내용은 본인 확인이 필요합니다.")
elif status == "no_key":
print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n",
file=sys.stderr)
print(text)
else:
print(text)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Consolidate the AI-Hub "한국어 SNS 멀티턴 대화" TS/TL/VS/VL zip parts into
clean train/val JSONL for PoC #1 (base tone-generation corpus).
Usage:
python3 prepare_dataset.py --input <dir with the *.zip parts> --output <output dir>
Input zips are matched by filename substring: files containing "VL_" or "TL_"
are the labeled JSON splits (VL=validation, TL=train) and are the only ones
consumed for the JSONL output -- they already carry the full utterance text
plus speech_act/slot labels, so the raw CSV (VS_/TS_) is redundant for this
purpose. VS_/TS_ zips are only used for the optional --qa cross-check.
Does NOT commit or move the source zips anywhere -- run this against a local
copy of the AI-Hub download, and keep both the source zips and this script's
output out of git (see poc/tone-corpus/README.md).
"""
import argparse
import csv
import glob
import io
import json
import os
import zipfile
from collections import Counter
VALID_SEX = {"남자", "여자"}
def is_valid_age(value):
# raw field is a bare decade number as a string, e.g. "20", "30" -- not "20대".
return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0
def clean_speaker_field(value, kind):
if not value:
return None
if kind == "sex":
return value if value in VALID_SEX else None
return value if is_valid_age(value) else None
def iter_dialogues(zip_paths):
for path in zip_paths:
split = "val" if "VL_" in os.path.basename(path) else "train"
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if not name.endswith(".json"):
continue
with z.open(name) as f:
yield split, json.load(f)
def normalize(dialogue):
info = dialogue.get("info", {})
speaker = info.get("speaker", {})
speakers = {}
for letter in ("A", "B", "C"):
sid = speaker.get(f"speaker{letter}Id")
if not sid:
continue
speakers[letter] = {
"id": sid,
"sex": clean_speaker_field(speaker.get(f"speaker{letter}Sex"), "sex"),
"age": clean_speaker_field(speaker.get(f"speaker{letter}Age"), "age"),
}
utterances = [
{
"speaker": u.get("speaker", "").replace("speaker", ""),
"text": u.get("text", ""),
"speech_act": u.get("speech_act"),
"slot": u.get("slot") or [],
}
for u in dialogue.get("utterances", [])
]
return {
"id": info.get("id"),
"topic": info.get("topic"),
"keyword": info.get("keyword"),
"speakers": speakers,
"turns": len(utterances),
"utterances": utterances,
}
def run_qa(input_dir, seen_ids):
csv_paths = sorted(
glob.glob(os.path.join(input_dir, "*VS_*.zip"))
+ glob.glob(os.path.join(input_dir, "*TS_*.zip"))
)
csv_ids = set()
for path in csv_paths:
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if not name.endswith(".csv"):
continue
with z.open(name) as f:
text = io.TextIOWrapper(f, encoding="utf-8-sig")
for row in csv.DictReader(text):
did = row.get("대화ID")
if did:
csv_ids.add(did)
only_in_json = seen_ids - csv_ids
only_in_csv = csv_ids - seen_ids
print(f"[QA] 원천(CSV) 대화ID 수: {len(csv_ids)}")
print(f"[QA] 라벨(JSON)에만 있음: {len(only_in_json)}")
print(f"[QA] 원천(CSV)에만 있음: {len(only_in_csv)}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True, help="원본 zip 파일들이 있는 디렉토리")
ap.add_argument("--output", required=True, help="train.jsonl/val.jsonl을 쓸 디렉토리")
ap.add_argument("--qa", action="store_true", help="VS/TS 원천 CSV와 대화ID 교차검증")
args = ap.parse_args()
os.makedirs(args.output, exist_ok=True)
zip_paths = sorted(
glob.glob(os.path.join(args.input, "*VL_*.zip"))
+ glob.glob(os.path.join(args.input, "*TL_*.zip"))
)
if not zip_paths:
raise SystemExit(f"VL_/TL_ zip을 {args.input}에서 찾지 못했습니다.")
writers = {
"train": open(os.path.join(args.output, "train.jsonl"), "w", encoding="utf-8"),
"val": open(os.path.join(args.output, "val.jsonl"), "w", encoding="utf-8"),
}
topic_counts = Counter()
split_counts = Counter()
dropped_age = 0
dropped_sex = 0
seen_ids = set()
try:
for split, raw in iter_dialogues(zip_paths):
record = normalize(raw)
seen_ids.add(record["id"])
split_counts[split] += 1
topic_counts[record["topic"]] += 1
for s in record["speakers"].values():
if s["age"] is None:
dropped_age += 1
if s["sex"] is None:
dropped_sex += 1
writers[split].write(json.dumps(record, ensure_ascii=False) + "\n")
finally:
for w in writers.values():
w.close()
stats = {
"total_dialogues": sum(split_counts.values()),
"split_counts": dict(split_counts),
"topic_counts": dict(topic_counts.most_common()),
"speaker_fields_dropped_as_noise": {"age": dropped_age, "sex": dropped_sex},
}
with open(os.path.join(args.output, "stats.json"), "w", encoding="utf-8") as f:
json.dump(stats, f, ensure_ascii=False, indent=2)
print(f"완료: {stats['total_dialogues']}건 -> {args.output}/{{train,val}}.jsonl")
print(f"분할: {stats['split_counts']}")
print(f"이상치로 제외된 speaker 필드 - age: {dropped_age}, sex: {dropped_sex}")
if args.qa:
run_qa(args.input, seen_ids)
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
google-genai>=1.0.0

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Keyword/recency-based retrieval -- the search step of the personalization
layer in `docs/tech-design.md` §2-1: "지금부터 임베딩 인프라를 먼저 만들지
않는다" -- so this is plain token overlap + recency, not embeddings.
Given a person's message history (one message per line, oldest first) and
the incoming message they need to reply to, returns the top-k past messages
most likely to show the relevant tone -- meant to feed `generate_draft.py`'s
--style input instead of a hand-curated file.
Usage:
python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6
"""
import argparse
import re
TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]+")
def tokenize(text):
return set(TOKEN_RE.findall(text))
def _jaccard(a, b):
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def retrieve(history_messages, query_text, k=6):
"""history_messages: oldest-first list of past messages by the person.
Ranked by keyword overlap with query_text first; recency only breaks
ties. A weighted-sum of the two was tried and dropped -- Korean short
messages rarely share more than one or two tokens (no particle
stripping here, so "핀란드" and "핀란드는" don't match each other),
so any nonzero recency weight ended up drowning out real overlap and
just returning the most recent messages regardless of topic."""
query_tokens = tokenize(query_text)
n = len(history_messages)
scored = []
for i, msg in enumerate(history_messages):
overlap = _jaccard(tokenize(msg), query_tokens)
recency = i / max(n - 1, 1)
scored.append(((overlap, recency), msg))
scored.sort(key=lambda pair: pair[0], reverse=True)
return [msg for _, msg in scored[:k]]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--history", required=True, help="과거 발화 파일 (한 줄에 하나, 오래된 것부터)")
ap.add_argument("--query", required=True, help="지금 답장해야 할 상대 메시지")
ap.add_argument("--k", type=int, default=6)
args = ap.parse_args()
with open(args.history, encoding="utf-8") as f:
history = [l.strip() for l in f if l.strip()]
for msg in retrieve(history, args.query, k=args.k):
print(msg)
if __name__ == "__main__":
main()