Start Phase 1 backend skeleton (item 2 of the build order)

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.
This commit is contained in:
Claude 2026-07-30 00:57:57 +00:00
parent 94479a51d6
commit 9a5358d0b1
No known key found for this signature in database
9 changed files with 311 additions and 4 deletions

3
.gitignore vendored
View File

@ -11,3 +11,6 @@ poc/tone-corpus/data/
__pycache__/ __pycache__/
*.pyc *.pyc
# Local dev DB (backend/, SQLite fallback for DATABASE_URL)
*.db

39
backend/README.md Normal file
View File

@ -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 같은 마이그레이션은 스키마가 안정되면 도입)

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

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

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

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

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

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

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

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

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

6
backend/requirements.txt Normal file
View File

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

View File

@ -33,9 +33,11 @@
#### 2. 워크스트림별 작업 #### 2. 워크스트림별 작업
**2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능) **2.1 백엔드 인프라** (PoC 결과 무관 — 지금 착수 가능)
- [ ] 계정/인증 (초대 코드 기반 가입) - [x] 계정/인증 (초대 코드 기반 가입) — `backend/app/main.py` `POST /auth/signup`, 중복 코드 409 확인함
- [ ] 메시지 릴레이 서버 (송수신, 멀티 디바이스 동기화) - [x] 메시지 릴레이 서버 (송수신) — `backend/app/main.py` WebSocket + REST, 실제 TestClient로 브로드캐스트 확인함.
- [ ] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐
- [x] DB 스키마: users, contacts, conversations, messages, twin_settings, escalation_logs, whitelist_rules
`backend/app/models.py`
- [ ] 푸시 알림 서비스 연동 - [ ] 푸시 알림 서비스 연동
**2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격) **2.2 AI 파이프라인 프로덕션화** (PoC 스크립트 → 서비스로 승격)
@ -76,7 +78,8 @@
#### 4. 권장 착수 순서 (진행 상황) #### 4. 권장 착수 순서 (진행 상황)
1. [x] §1 기술 스택 결정 1. [x] §1 기술 스택 결정
2. [ ] 2.1 백엔드 기본 인프라 + 2.3 채팅 UI 뼈대 (병행) — **다음 작업** 2. [~] 2.1 백엔드 기본 인프라 (계정/인증, DB 스키마, 메시지 릴레이 — `backend/` 완료, 푸시 알림만 남음)
+ 2.3 채팅 UI 뼈대 (병행) — **안드로이드 클라이언트 쪽이 다음 작업**
3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용) 3. [ ] 2.2 AI 파이프라인 프로덕션화 (PoC 스크립트 재사용)
4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) 4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지)
5. [ ] 2.4/2.5 안전장치·QA 5. [ ] 2.4/2.5 안전장치·QA