diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..070cc05 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4b314 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f10ddc7 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c17c295 --- /dev/null +++ b/CLAUDE.md @@ -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/`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4acd409 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/ai-service/README.md b/ai-service/README.md new file mode 100644 index 0000000..57ac0fe --- /dev/null +++ b/ai-service/README.md @@ -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 쪽 작업) diff --git a/ai-service/app/__init__.py b/ai-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/app/escalation_filter.py b/ai-service/app/escalation_filter.py new file mode 100644 index 0000000..b350a03 --- /dev/null +++ b/ai-service/app/escalation_filter.py @@ -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) diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py new file mode 100644 index 0000000..bfc6d6f --- /dev/null +++ b/ai-service/app/generation.py @@ -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() diff --git a/ai-service/app/main.py b/ai-service/app/main.py new file mode 100644 index 0000000..c58e813 --- /dev/null +++ b/ai-service/app/main.py @@ -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) diff --git a/ai-service/app/retrieve_style.py b/ai-service/app/retrieve_style.py new file mode 100644 index 0000000..bea46aa --- /dev/null +++ b/ai-service/app/retrieve_style.py @@ -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]] diff --git a/ai-service/pytest.ini b/ai-service/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/ai-service/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/ai-service/requirements-dev.txt b/ai-service/requirements-dev.txt new file mode 100644 index 0000000..13f6026 --- /dev/null +++ b/ai-service/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0.0 diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt new file mode 100644 index 0000000..7ba5599 --- /dev/null +++ b/ai-service/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.0.0 +google-genai>=1.0.0 diff --git a/ai-service/tests/__init__.py b/ai-service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/tests/test_escalation_filter.py b/ai-service/tests/test_escalation_filter.py new file mode 100644 index 0000000..cfd0cd3 --- /dev/null +++ b/ai-service/tests/test_escalation_filter.py @@ -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 == "감정적으로 무거운 주제" diff --git a/ai-service/tests/test_generation.py b/ai-service/tests/test_generation.py new file mode 100644 index 0000000..1bf4725 --- /dev/null +++ b/ai-service/tests/test_generation.py @@ -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"] diff --git a/ai-service/tests/test_main.py b/ai-service/tests/test_main.py new file mode 100644 index 0000000..311a398 --- /dev/null +++ b/ai-service/tests/test_main.py @@ -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 diff --git a/ai-service/tests/test_retrieve_style.py b/ai-service/tests/test_retrieve_style.py new file mode 100644 index 0000000..5096acf --- /dev/null +++ b/ai-service/tests/test_retrieve_style.py @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..08de9b1 --- /dev/null +++ b/backend/README.md @@ -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 같은 마이그레이션은 스키마가 안정되면 도입) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..25296fb --- /dev/null +++ b/backend/app/config.py @@ -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") diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..b4cc0e2 --- /dev/null +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..010b31a --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..361d7c4 --- /dev/null +++ b/backend/app/models.py @@ -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..be191dd --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/core-backend/README.md b/core-backend/README.md new file mode 100644 index 0000000..0ada203 --- /dev/null +++ b/core-backend/README.md @@ -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 등 도입) +- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우) diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go new file mode 100644 index 0000000..7bd6df1 --- /dev/null +++ b/core-backend/aiservice.go @@ -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 +} diff --git a/core-backend/config.go b/core-backend/config.go new file mode 100644 index 0000000..f0a0242 --- /dev/null +++ b/core-backend/config.go @@ -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" +} diff --git a/core-backend/db.go b/core-backend/db.go new file mode 100644 index 0000000..70f03ac --- /dev/null +++ b/core-backend/db.go @@ -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 +} diff --git a/core-backend/go.mod b/core-backend/go.mod new file mode 100644 index 0000000..f4613b5 --- /dev/null +++ b/core-backend/go.mod @@ -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 +) diff --git a/core-backend/go.sum b/core-backend/go.sum new file mode 100644 index 0000000..182699d --- /dev/null +++ b/core-backend/go.sum @@ -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= diff --git a/core-backend/main.go b/core-backend/main.go new file mode 100644 index 0000000..d503539 --- /dev/null +++ b/core-backend/main.go @@ -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") +} diff --git a/core-backend/main_test.go b/core-backend/main_test.go new file mode 100644 index 0000000..5661934 --- /dev/null +++ b/core-backend/main_test.go @@ -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()) +} diff --git a/core-backend/models.go b/core-backend/models.go new file mode 100644 index 0000000..e5c850b --- /dev/null +++ b/core-backend/models.go @@ -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{}, +} diff --git a/core-backend/relay.go b/core-backend/relay.go new file mode 100644 index 0000000..17e7269 --- /dev/null +++ b/core-backend/relay.go @@ -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) + } +} diff --git a/docs/PLANNING.md b/docs/PLANNING.md new file mode 100644 index 0000000..cf86a00 --- /dev/null +++ b/docs/PLANNING.md @@ -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, 회의 필요 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..8c46164 --- /dev/null +++ b/docs/PRD.md @@ -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 화이트리스트 자동발송 후 되돌리기 사용률 — 낮을수록 신뢰 신호 diff --git a/docs/decision-log.md b/docs/decision-log.md new file mode 100644 index 0000000..f50ce29 --- /dev/null +++ b/docs/decision-log.md @@ -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은 가칭) +- 베타 참가자 모집 규모와 방식 (§로드맵 참고) diff --git a/docs/idea-meeting-2026-06-29.html b/docs/idea-meeting-2026-06-29.html new file mode 100644 index 0000000..79b013c --- /dev/null +++ b/docs/idea-meeting-2026-06-29.html @@ -0,0 +1,579 @@ + + +
+ + +브레인스토밍한 전체 아이디어와, 그중 가장 특색 있는 AI 분신 메신저 설계를 한 곳에 모았습니다. 회의 진행용 자료입니다.
+ + +카톡에 없거나 카톡이 못 하는 것들. 발산 단계에서 모은 전체 후보입니다.
+ +회사든 대중이든 모두가 공감하는 페인을 정확히 때리는 셋.
+정체성 한 줄 — 챗GPT가 나와 대화하는 AI라면, 분신은 나를 대신해 남과 대화하는 나다.
+ + +분신이 1차로 받아 정리하고, 민감·중요한 건만 나에게 올린다. 가벼운 건 ④에서 분신이 바로 응답.
+두 분신이 협상한 뒤 양쪽 본인에게 후보 시간만 제안 → 최종 확정은 사람이. (둘 다 앱이 있어야 작동 = 초대 동기)
+"새 메신저 앱"이 아니라 지금 쓰는 모든 대화 위에 얹히는 AI 레이어로 재정의하면 어떨까. 채택 장벽 자체를 없애는 접근.
+ + ✦ 카톡 대안이 아니라 — 모든 대화의 AI 비서 + + + + + +각 앱은 그대로 두고, 분신이 알림 접근 권한으로 관통해 동일한 경험을 제공. 자체 메신저는 "완전판"으로 나중에 자연스럽게 유도.
+"메신저 옮기기"라는 가장 큰 진입 장벽이 사라짐. 앱 설치 후 권한만 허용하면 바로 가치 체감.
"카톡 대안 메신저" 시장이 아니라 "모든 대화의 AI 비서" 시장으로 — 타깃 인구 자체가 훨씬 커짐.
상대가 앱이 없어도 나는 즉시 가치를 얻음(L0~L1). 분신 협상(L4)만 "둘 다 앱 있어야" 유효 — 압박감 없는 자연 확산.
모든 채널의 요약·결정 로그가 한곳에 모이는 "허브"가 되면, 이후 자체 메신저·B2B 확장의 자연스러운 발판이 됨.
이 자료를 띄워놓고 함께 결정하면 좋을 질문들입니다.
+