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:
commit
6986c4e15f
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/`.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 쪽 작업)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]]
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
pythonpath = .
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-r requirements.txt
|
||||
pytest>=8.0.0
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
pydantic>=2.0.0
|
||||
google-genai>=1.0.0
|
||||
|
|
@ -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 == "감정적으로 무거운 주제"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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 등 도입)
|
||||
- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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{},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, 회의 필요
|
||||
|
|
@ -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 화이트리스트 자동발송 후 되돌리기 사용률 — 낮을수록 신뢰 신호
|
||||
|
|
@ -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은 가칭)
|
||||
- 베타 참가자 모집 규모와 방식 (§로드맵 참고)
|
||||
|
|
@ -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> — 대중·회사까지 아우르는 대표 메신저 | <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>
|
||||
|
|
@ -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 체크리스트에 담당자/일정 메모 추가
|
||||
|
|
@ -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인이 양쪽에 다 참여할 수도 있음
|
||||
|
|
@ -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`에 반영
|
||||
|
|
@ -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을 최우선으로 진행한다.
|
||||
|
|
@ -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 근거).
|
||||
|
|
@ -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 의존 항목이다 — 여기서 같이 정하지 않는다.
|
||||
|
|
@ -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`
|
||||
역할극(상대방 관점)** 순으로 진행하는 걸 권장한다 — 먼저 "내가 위임하고 싶은 정도"를 스스로
|
||||
답하게 한 뒤에 "상대방 입장"을 시켜보면, 두 관점의 온도차 자체가 흥미로운 신호가 된다.
|
||||
|
|
@ -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라면, 분신은 나를 대신해 남과 대화하는 나.
|
||||
- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은
|
||||
어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다.
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 범위 밖)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = "../.."
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.bunsin.bunsin_mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('시작하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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` 참고).
|
||||
- 실제 모델 학습에 쓰기 전에, 이 데이터가 세션이 아닌 영구 저장소(본인 로컬/스토리지)로 옮겨졌는지
|
||||
확인할 것 — 클라우드 세션은 종료되면 임시 파일이 사라진다.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
google-genai>=1.0.0
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue