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