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:
parent
94479a51d6
commit
9a5358d0b1
|
|
@ -11,3 +11,6 @@ poc/tone-corpus/data/
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Local dev DB (backend/, SQLite fallback for DATABASE_URL)
|
||||||
|
*.db
|
||||||
|
|
|
||||||
|
|
@ -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,0 +1,8 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Production: postgresql+psycopg2://user:pass@host/dbname (tech-design.md §8).
|
||||||
|
# Defaults to a local SQLite file so the app runs without a Postgres instance
|
||||||
|
# during development/testing.
|
||||||
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./dev.db")
|
||||||
|
|
||||||
|
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from .config import DATABASE_URL
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args=connect_args)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from .db import Base, engine, get_db
|
||||||
|
|
||||||
|
app = FastAPI(title="분신 backend")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def create_tables():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class SignupRequest(BaseModel):
|
||||||
|
invite_code: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/signup")
|
||||||
|
def signup(req: SignupRequest, db: Session = Depends(get_db)):
|
||||||
|
existing = db.query(models.User).filter_by(invite_code=req.invite_code).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="invite_code already used")
|
||||||
|
user = models.User(invite_code=req.invite_code, display_name=req.display_name)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
db.add(models.TwinSettings(user_id=user.id))
|
||||||
|
db.commit()
|
||||||
|
return {"id": user.id, "display_name": user.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
sender_id: int
|
||||||
|
text: str
|
||||||
|
sender_mode: models.SenderMode = models.SenderMode.HUMAN
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/conversations/{conversation_id}/messages")
|
||||||
|
async def send_message(conversation_id: int, req: SendMessageRequest, db: Session = Depends(get_db)):
|
||||||
|
conversation = db.query(models.Conversation).get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="conversation not found")
|
||||||
|
message = models.Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
sender_id=req.sender_id,
|
||||||
|
sender_mode=req.sender_mode,
|
||||||
|
text=req.text,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(message)
|
||||||
|
await relay.broadcast(conversation_id, {
|
||||||
|
"id": message.id,
|
||||||
|
"sender_id": message.sender_id,
|
||||||
|
"sender_mode": message.sender_mode.value,
|
||||||
|
"text": message.text,
|
||||||
|
})
|
||||||
|
return {"id": message.id}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""In-memory WebSocket fan-out per conversation. Fine for a small closed
|
||||||
|
beta (roadmap.md Phase 1); revisit if the relay needs to scale past one
|
||||||
|
process."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connections: Dict[int, List[WebSocket]] = {}
|
||||||
|
|
||||||
|
async def connect(self, conversation_id: int, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.connections.setdefault(conversation_id, []).append(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, conversation_id: int, websocket: WebSocket):
|
||||||
|
conns = self.connections.get(conversation_id, [])
|
||||||
|
if websocket in conns:
|
||||||
|
conns.remove(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, conversation_id: int, payload: dict):
|
||||||
|
for ws in self.connections.get(conversation_id, []):
|
||||||
|
await ws.send_json(payload)
|
||||||
|
|
||||||
|
|
||||||
|
relay = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/conversations/{conversation_id}")
|
||||||
|
async def conversation_socket(websocket: WebSocket, conversation_id: int):
|
||||||
|
await relay.connect(conversation_id, websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
relay.disconnect(conversation_id, websocket)
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AutonomyLevel(str, enum.Enum):
|
||||||
|
L0 = "L0"
|
||||||
|
L1 = "L1"
|
||||||
|
L2 = "L2"
|
||||||
|
|
||||||
|
|
||||||
|
class SenderMode(str, enum.Enum):
|
||||||
|
HUMAN = "human"
|
||||||
|
TWIN = "twin"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
invite_code = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
display_name = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
twin_settings = relationship("TwinSettings", back_populates="user", uselist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base):
|
||||||
|
"""A relationship between the owner and a counterpart -- carries
|
||||||
|
per-peer state like the veto flag (tech-design.md §4:
|
||||||
|
twin_disabled_by_peer), independent of any one conversation."""
|
||||||
|
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
contact_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
display_name = Column(String, nullable=False)
|
||||||
|
relationship_note = Column(String, nullable=True)
|
||||||
|
twin_disabled_by_peer = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
is_group = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
participants = relationship("ConversationParticipant", back_populates="conversation")
|
||||||
|
messages = relationship("Message", back_populates="conversation")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationParticipant(Base):
|
||||||
|
__tablename__ = "conversation_participants"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
conversation = relationship("Conversation", back_populates="participants")
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
|
||||||
|
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
sender_mode = Column(Enum(SenderMode), nullable=False, default=SenderMode.HUMAN)
|
||||||
|
text = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
conversation = relationship("Conversation", back_populates="messages")
|
||||||
|
|
||||||
|
|
||||||
|
class TwinSettings(Base):
|
||||||
|
__tablename__ = "twin_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||||
|
autonomy_level = Column(Enum(AutonomyLevel), nullable=False, default=AutonomyLevel.L0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="twin_settings")
|
||||||
|
|
||||||
|
|
||||||
|
class WhitelistRule(Base):
|
||||||
|
"""L2 auto-send whitelist -- a (contact, topic) pair the owner has
|
||||||
|
approved for unattended replies. contact_id null = applies to any
|
||||||
|
counterpart (PRD.md §3.1)."""
|
||||||
|
|
||||||
|
__tablename__ = "whitelist_rules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
|
||||||
|
topic_keyword = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationLog(Base):
|
||||||
|
"""One row per escalation_filter.py trigger -- the post-hoc notification
|
||||||
|
+ undo trail required by AGENTS.md's absolute safety invariants."""
|
||||||
|
|
||||||
|
__tablename__ = "escalation_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
|
||||||
|
reason = Column(String, nullable=False)
|
||||||
|
message_snippet = Column(String, nullable=False)
|
||||||
|
resolved = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
psycopg2-binary>=2.9.0
|
||||||
|
websockets>=13.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue