diff --git a/docs/discord_first_redesign.md b/docs/discord_first_redesign.md new file mode 100644 index 0000000..0363975 --- /dev/null +++ b/docs/discord_first_redesign.md @@ -0,0 +1,611 @@ +# Lang2SQL — Discord-First 재설계 명세 + +> **작성일**: 2026-05-18 +> **결정자**: ryan@brain-crew.com +> **상태**: 골격 승인. §10 잔여 결정 항목 보완 후 Week 1 착수. +> **범위**: 현 `lang2sql/` 트리를 비우고 본 문서대로 새로 채운다. + +--- + +## 0. 제품 한 줄과 타이브레이커 + +> **"Discord에서 쓰는 read-only · audit-by-default SQL 에이전트. +> DB는 절대 변하지 않고, 모든 쿼리는 예산/시간/행 한도 안에서 돈다. +> 대화 맥락은 끊겨도 영속된다."** + +설계 충돌 시 우선순위: + +1. **Discord UX가 단순한가?** +2. **DB는 안 깨지는가?** +3. **맥락은 보존되는가?** + +이 3가지에 부정으로 답하는 어떤 추상화도 도입하지 않는다. + +--- + +## 1. 확정된 기술 결정 + +| # | 항목 | 선택 | 근거 | +|---|---|---|---| +| 1 | Discord lib | **`discord.py` 2.x** | 가장 성숙·안정. 문서/예제 풍부. 비동기 native. | +| 2 | DB 엔진 타깃 (V1) | **PostgreSQL** | EXPLAIN·statement_timeout·RO role·sslmode 등 모든 safety 기법이 정직하게 동작. MySQL/SQLite/BigQuery는 V2 이후. | +| 3 | 세션 영속화 | **Hermes-style write-through** | 매 메시지·매 도구 호출/결과·매 pause·매 audit 항목을 즉시 디스크에 flush. 봇 재시작/배포/네트워크 단절 후 다시 붙어도 동일 맥락 복구. | +| 4 | 세션 + 자격증명 스토어 | **AES-GCM 암호화된 SQLite 단일 파일** | 트라이얼 단계 무료·무의존. JSON1으로 conversation 저장. 외부 secret manager는 V2 옵션. | +| 5 | LLM 백엔드 | **OpenAI + NVIDIA NIM 듀얼 어댑터** | NIM endpoint가 OpenAI 호환(`/v1/chat/completions`) → 어댑터 거의 그대로 재사용. 슬래시 명령 `/model` 로 유저 전환. | +| 6 | Go TUI | **유지 (개발 도구)** | Discord 불가 환경 fallback. `serve.py` NDJSON 인터페이스 유지. | +| 7 | 차트 출력 | **PNG 첨부 + 자동 페이지네이션** | matplotlib + Pillow. Discord 첨부 한도(무료 8MB/메시지, 임베드 10개)에 맞춰 잘라 보냄. | +| 8 | 호스팅 (트라이얼) | **소형 VPS** | §1.1 참조. Cloudflare Workers 부적합. | + +### 1.1 호스팅 — "Cloudflare 5천원" 건에 대한 솔직한 답 + +`discord.py` 는 Discord WebSocket gateway에 **계속 붙어 있어야 하는 장기 실행 Python 프로세스**입니다. Cloudflare Workers의 edge runtime은 짧은 HTTP 요청-응답 모델이라 봇 본체를 못 올립니다. (D1/KV/R2 같은 backing store로는 활용 가능하지만 V1 범위 밖.) + +**저렴·트라이얼 권장 옵션** + +| 옵션 | 비용 | 메모 | +|---|---|---| +| **Oracle Cloud Always Free** | $0 (무기한) | ARM Ampere 4코어/24GB. 가성비 최강. 가입 시 카드 검증. | +| **fly.io shared-cpu-1x** | $0 ~ $5/월 | 256MB로 시작 가능. 한국에서 가까운 region(NRT/SIN). | +| **Hetzner CX11** | €4.5/월 (~6,500원) | 안정·빠름. 유럽 region. | +| **본인 PC + 24h on** | $0 | 데모 단계 충분. 동적 IP면 이주 권장. | + +**비추**: Cloudflare Workers(런타임 비호환), Heroku 무료(폐지), Render 무료(60s sleep으로 봇 끊김). + +### 1.2 "타깃 = 가짜연구소 디스코드?" 정리 + +질문하신 *"이 타깃이 가짜연구소 디스코드?"* 에 대해: 문서에서 쓴 "타깃"은 **지원할 DB 엔진 종류**(PG vs MySQL vs BQ)를 의미한 것이고, **봇이 초대될 디스코드 커뮤니티**와는 별개입니다. + +- **DB 엔진 타깃**: PostgreSQL (확정) +- **배포 커뮤니티**: 가짜연구소 디스코드라면 그쪽 길드에 봇을 초대하는 별개 결정. V1 코드는 어떤 길드에든 동작하도록 작성. + +### 1.3 "자격증명 저장 위치" 풀이 + +`/connect` 로 입력받는 **DB 사용자명·비밀번호를 어디에 저장하느냐**의 질문입니다. + +| 옵션 | 비용 | 강도 | 추천 시점 | +|---|---|---|---| +| **로컬 AES-GCM SQLite** | $0 | 마스터키(env) + per-row IV | **V1 — 채택** | +| AWS Secrets Manager | ~$0.40/secret/월 | 강함 | 다중 인스턴스·SOC2 | +| GCP Secret Manager | ~$0.06/secret/월 | 강함 | GCP 환경일 때 | +| HashiCorp Vault | self-host | 강함 | 사내 운영 | + +V1은 **로컬 암호화 SQLite**. 마스터키는 `LANG2SQL_MASTER_KEY` env로 주입. 키 분실 시 secrets만 무효화되고 conversation/audit은 그대로 (분리 저장). + +--- + +## 2. 상위 아키텍처 + +``` + ┌──────────────────────────────┐ + │ DISCORD GATEWAY │ + │ - DM (private analytics) │ + │ - Guild channel / thread │ + │ - Slash commands / modals │ + └───────────────┬──────────────┘ + │ events + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ DISCORD ADAPTER (1급) │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │ +│ │ Onboarding │ │ SessionRouter│ │ Streaming Renderer │ │ +│ │ /connect │ │ key 결정 │ │ 토큰 → message edits │ │ +│ │ modal+test │ │ │ │ rows → PNG (페이지) │ │ +│ └──────────────┘ └──────────────┘ └───────────────────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │ +│ │ Interactive │ │ Permissions │ │ DiscordRateLimit │ │ +│ │ ask_user / │ │ guild-admin/ │ │ message edit throttle │ │ +│ │ show_plan │ │ owner/user │ │ per-user / per-guild │ │ +│ │ → buttons │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └───────────────────────────┘ │ +└───────────────────────────┬──────────────────────────────────────────┘ + │ ctx = ContextConcierge.build(session_key, principal) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ TENANCY LAYER │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │ +│ │TenantRegistry│ │ExplorerCache │ │ EncryptedStore (AES-GCM) │ │ +│ │key→DBSpec │ │LRU engines │ │ secrets + conversation + │ │ +│ │ │ │ │ │ audit (단일 SQLite) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ ContextConcierge — session_key + principal → HarnessContext │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬──────────────────────────────────────────┘ + ▼ ctx +┌──────────────────────────────────────────────────────────────────────┐ +│ HARNESS KERNEL │ +│ agent_loop · system_prompt(per turn) · ToolRegistry(ctx 주입) │ +│ Session(live layer + pending_call + write-through) · Hooks(pub/sub) │ +└──────┬─────────────────────┬──────────────────┬──────────────────────┘ + │ ports │ │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ SAFETY LAYER (제품의 moat) │ +│ L0 connect (RO role, TLS) → L1 stmt gate (sqlglot AST) │ +│ L2 cost gate (EXPLAIN) → L3 runtime (timeout/row/byte) │ +│ L4 audit (append-only) → L5 rate limit (token bucket) │ +└──────┬─────────────────────┬──────────────────┬──────────────────────┘ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ OUTBOUND ADAPTERS │ +│ LLM (openai / nvidia-nim) · DB (sqlalchemy-postgres) │ +│ Secrets (encrypted-sqlite) · SessionStore (encrypted-sqlite) │ +│ AuditSink (sqlite, file) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +규칙: 화살표는 **안쪽으로만**. Kernel/Safety는 어댑터를 import하지 않는다. 새 frontend·새 LLM·새 DB·새 session store는 어댑터 한 장 추가로 끝. + +--- + +## 3. 세션 전략 — Hermes-style 영속화 + +### 3.1 session_key 정책 + +| Discord 컨텍스트 | session_key | DB credential 출처 | 멤버 가시성 | 용도 | +|---|---|---|---|---| +| **봇과의 DM** | `dm:{user_id}` | 유저 본인의 비밀저장소 항목 | 본인만 | **개인 분석 (기본 경로)** | +| **길드 채널(메인)** | `chan:{guild_id}:{channel_id}` | 길드 어드민이 등록한 공용 DB | 채널 멤버 전원 | 팀 공용 대시보드 질의 | +| **길드 스레드** | `thr:{guild_id}:{channel_id}:{thread_id}` | 상위 채널과 동일 | 스레드 참여자 | 병렬 조사 (1조사 = 1스레드) | + +**원칙**: +- 민감 DB 자격증명은 **DM에서만 받음**. 채널 메시지는 영구히 history에 남고 다른 멤버에게 보임. +- 위 3가지 외의 session_key는 사용 금지. 변형이 늘면 "누가 무엇을 보는지" 추적 불가. +- **principal** (= 누가 보낸 메시지인가) 은 session_key와 별개로 매 요청 기록. 공용 채널에서도 audit log에 *"이 쿼리는 누가 시켰는지"* 가 남음. + +### 3.2 영속화 메커니즘 (write-through) + +``` +User msg ──▶ Discord adapter ──▶ session.append_message(msg) + │ + ├─▶ in-memory state update + └─▶ store.write(session_id, msg) ← 동기, 즉시 + │ + ▼ + AES-GCM SQLite (append-only segment) + +Tool call/result ──▶ same path, immediate flush +Pause(ask_user) ──▶ pending_call snapshot, immediate flush +LLM stream delta ──▶ buffered in-mem, flush at chunk boundary +``` + +**보장**: 봇 프로세스가 임의 시점에 죽어도 *디스크에 마지막으로 flush된 상태까지는* 안전. 재시작 시: + +1. `SessionStore.iter_active(since=now-30d)` 로 미완 세션 로드 +2. `pending_call` 이 있는 세션은 *사용자 답을 기다리는 상태*로 복원 +3. Discord 어댑터가 DM·채널·스레드별로 reattach +4. 사용자가 다음 메시지를 보내면 **끊김 없이 이어짐** — 이게 Hermes-style 핵심 + +**저장 모델** (SQLite 스키마): + +```sql +-- 세션 메타 +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- session_key + principal TEXT NOT NULL, -- 마지막 발화 유저 + kind TEXT NOT NULL, -- 'dm' | 'channel' | 'thread' + db_spec_id TEXT, -- secrets.id 참조 + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + pending_call TEXT, -- JSON | NULL + closed_at TIMESTAMP -- NULL = active +); + +-- 메시지 append-only 로그 +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id), + ts TIMESTAMP NOT NULL, + role TEXT NOT NULL, -- system|user|assistant|tool_result + content TEXT, -- JSON or text + tool_calls TEXT, -- JSON | NULL + tool_call_id TEXT, + principal TEXT +); +CREATE INDEX ON messages(session_id, id); + +-- 암호화 자격증명 +CREATE TABLE secrets ( + id TEXT PRIMARY KEY, -- e.g. "user:123:default" + owner TEXT NOT NULL, -- discord user id + label TEXT, -- "prod-pg" 같은 표시명 + ciphertext BLOB NOT NULL, -- AES-GCM + iv BLOB NOT NULL, + tag BLOB NOT NULL, + created_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP +); + +-- 감사 로그 (append-only) +CREATE TABLE audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TIMESTAMP NOT NULL, + session_id TEXT, + principal TEXT, + kind TEXT, -- 'sql' | 'tool' | 'pause' | 'safety_block' + payload TEXT, -- JSON + duration_ms INTEGER, + status TEXT -- 'ok' | 'error' | 'blocked' +); +``` + +### 3.3 동시성 + +``` +전역 concurrency = 무제한 (asyncio) +per-session = FIFO 1 (같은 conversation에 동시 LLM 호출 금지) +per-user rate limit = token bucket (LLM 20/min, DB 60/min) +per-guild rate limit = token bucket (DB 200/min) +LLM stream = 세션마다 독립 (한 사용자가 폭주해도 다른 사용자 영향 X) +``` + +이 모델이면 **사용자 1000명도 단일 프로세스 처리 가능**. 한 유저가 폭주해도 다른 유저는 영향 없음. + +--- + +## 4. DB 연결 온보딩 (Discord-native) + +``` +사용자 Bot (DM) Tenancy Layer + │ │ │ + │── /connect ──────────▶│ │ + │ │ Modal 출력: │ + │ │ ┌──────────────────┐ │ + │ │ │ label: "prod-pg" │ │ + │ │ │ host: │ │ + │ │ │ port: 5432 │ │ + │ │ │ database: │ │ + │ │ │ user: │ │ + │ │ │ password: [hide] │ │ + │ │ │ schema: (opt) │ │ + │ │ │ sslmode: require │ │ + │ │ │ ☑ RO 계정인가요? │ │ + │ │ └──────────────────┘ │ + │── submit ────────────▶│ │ + │ │── build url ────────────────│ + │ │── encrypt (AES-GCM) ───────▶│ secrets.put + │ │── test SELECT 1 ───────────▶│ Explorer 임시 + │ │── probe RO (BEGIN; CREATE TABLE __probe; ROLLBACK) + │ │ - 실패하면 ✅ RO 확정 │ + │ │ - 성공하면 ⚠️ 경고 + 명시적 confirm + │ │── SHOW TABLES ──────────────│ + │ │◀── ok, 17 tables ───────────│ + │◀── ephemeral embed ───│ │ + │ ✅ Connected to │ │ + │ "prod-pg" (17 tbls) │ │ + │ Try: "tables 보여줘"│ │ + │ │ │ + │── "tables 보여줘" ───▶│ │ + │ │── agent_loop(ctx) ─────────▶│ kernel +``` + +**핵심 결정**: +- **Modal로만** 자격증명 입력 (슬래시 명령 인자로 URL 받지 않음) → 비밀번호가 채널 로그에 안 남음. +- **자동 RO 검증**: `BEGIN; CREATE TABLE __probe(x int); ROLLBACK;` 시도 → 실패하면 RO 확정, 성공하면 경고. +- **`db_url`은 절대 평문 저장 안 함**. AES-GCM 암호화 후 SQLite secrets 테이블에 저장. +- **여러 DB 등록 가능**: `/connections` 로 목록, `/use