From 9a5358d0b10878ad7d94ec8b3efbd8596046b75c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:57:57 +0000 Subject: [PATCH] Start Phase 1 backend skeleton (item 2 of the build order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI app with invite-code signup, message send/relay over WebSocket, and the DB schema from roadmap.md Phase 1 §2.1 (users, contacts, conversations, messages, twin_settings, whitelist_rules, escalation_logs). Defaults to SQLite for local dev, PostgreSQL in prod per tech-design.md §8. Verified end-to-end with TestClient: signup, duplicate-invite-code rejection (409), message persistence, 404 on an unknown conversation, and WebSocket broadcast delivery all behave as expected. Push notifications and the AI pipeline integration (item 3) are not in this commit -- see backend/README.md and roadmap.md's checklist. --- .gitignore | 3 + backend/README.md | 39 ++++++++++++ backend/app/__init__.py | 0 backend/app/config.py | 8 +++ backend/app/db.py | 17 ++++++ backend/app/main.py | 103 +++++++++++++++++++++++++++++++ backend/app/models.py | 128 +++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 6 ++ docs/roadmap.md | 11 ++-- 9 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/requirements.txt diff --git a/.gitignore b/.gitignore index 37de000..7152287 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ poc/tone-corpus/data/ __pycache__/ *.pyc + +# Local dev DB (backend/, SQLite fallback for DATABASE_URL) +*.db diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..00e89f2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# 분신 backend + +Phase 1 백엔드 인프라 뼈대 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 +`docs/tech-design.md` §8 참고 — Python/FastAPI, PostgreSQL(프로덕션)/SQLite(로컬 개발), +WebSocket 릴레이. + +## 실행 + +```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/docs/roadmap.md b/docs/roadmap.md index af54a6c..e4654d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,9 +33,11 @@ #### 2. 워크스트림별 작업 **2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) -- [ ] 계정/인증 (초대 코드 기반 가입) -- [ ] 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) -- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules +- [x] 계정/인증 (초대 코드 기반 가입) — `backend/app/main.py` `POST /auth/signup`, 중복 코드 409 확인함 +- [x] 메시지 릴레이 서버 (송수신) — `backend/app/main.py` WebSocket + REST, 실제 TestClient로 브로드캐스트 확인함. + 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 +- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules + — `backend/app/models.py` - [ ] 푸시 알림 서비스 연동 **2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) @@ -76,7 +78,8 @@ #### 4. 권장 착수 순서 (진행 상황) 1. [x] §1 기술 스택 결정 -2. [ ] 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) — **다음 작업** +2. [~] 2.1 백엔드 기본 인프라 (계정/인증, DB 스키마, 메시지 릴레이 — `backend/` 완료, 푸시 알림만 남음) + + 2.3 채팅 UI 뼈대 (병행) — **안드로이드 클라이언트 쪽이 다음 작업** 3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 5. [ ] 2.4/2.5 안전장치·QA