diff --git a/core-backend/README.md b/core-backend/README.md index a5fb016..0ada203 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -32,8 +32,9 @@ go test ./... -v `main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket 브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성 플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/ -거부권(peer veto, 다른 모든 조건보다 우선)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 -돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). +거부권(peer veto, 다른 모든 조건보다 우선)/화이트리스트 규칙 CRUD/메시지 되돌리기(twin 전용, WebSocket +브로드캐스트 포함)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 돌려서 확인한다 +(`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들). ## 지금 있는 것 @@ -47,6 +48,11 @@ go test ./... -v - `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다. 이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이 전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음) +- `POST /messages/{id}/retract` — 되돌리기(one-tap undo, PRD.md §3.1): **트윈이 자동발송한 + 메시지만** 대상. 사람이 직접 쓴 메시지는 400. 이미 되돌린 메시지를 다시 호출하면 409. 성공하면 + `Message.Retracted`를 켜고 같은 대화방의 WebSocket 연결에 `{"type": "retraction", "id": ...}`를 + 브로드캐스트한다 (아래 참고: 일반 메시지 브로드캐스트도 이제 `"type": "message"`를 포함해서 + 클라이언트가 두 이벤트를 구분할 수 있게 함) - `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저) - `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트· 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴 @@ -79,12 +85,16 @@ go test ./... -v 필요해져서 구현) - `PATCH /users/{id}/twin-settings` — `{"autonomy_level": "L0"|"L1"|"L2"}`로 전역 자율성 레벨 변경 (가입 시 기본값은 L0, PRD.md §2.1) +- `POST`/`GET /users/{id}/whitelist-rules`, `DELETE /users/{id}/whitelist-rules/{ruleId}` — + 화이트리스트 규칙 CRUD (`{"topic_keyword": "...", "contact_id": null|uint}`). `contact_id`는 + 저장은 되지만 아직 매칭에는 안 쓰인다 — 바로 아래 참고 - 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: **L0**은 항상 차단(403, 초안만 가능), **L1**은 요청에 `approved: true`가 없으면 차단(403), **L2**는 `whitelist_rules`에 매칭되는 주제면 `approved` 없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요 - 화이트리스트 매칭(`whitelistMatches`)은 v1 최소 구현 — 유저의 모든 `WhitelistRule.TopicKeyword`를 - 메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 아직 무시함 — - 대화방↔연락처 연결이 아직 모델링되어 있지 않아서(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) + 메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 CRUD로 + 저장은 되지만 매칭 로직에서는 아직 무시함 — 대화방↔연락처 연결이 아직 모델링되어 있지 않아서 + (클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) - 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved: true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함) @@ -99,11 +109,13 @@ go test ./... -v ## 아직 없는 것 (다음 워크스트림) -- 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로 - 등록하는 API는 아직 없음, 2.3 자율성 설정 화면과 같이 설계 필요) -- 상대별(`ContactID`) 화이트리스트/자율성 예외 (지금은 전역 레벨만 지원) +- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은 + 아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요) +- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 + 건 Flutter 쪽) +- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던 + 목록"을 조회하는 API는 아직 없음 — 필요해지면 추가) - 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임) -- 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽) - 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어 로컬 환경에서 진행) - 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음) diff --git a/core-backend/main.go b/core-backend/main.go index e4186fc..d503539 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -36,6 +36,11 @@ type updateTwinSettingsRequest struct { AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"` } +type createWhitelistRuleRequest struct { + ContactID *uint `json:"contact_id"` + TopicKeyword string `json:"topic_keyword" binding:"required"` +} + type draftMessageRequest struct { ContextLines []string `json:"context_lines" binding:"required"` StyleExamples []string `json:"style_examples"` @@ -242,6 +247,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } relay.broadcast(convID, gin.H{ + "type": "message", "id": message.ID, "sender_id": message.SenderID, "sender_mode": message.SenderMode, @@ -251,6 +257,44 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"id": message.ID}) }) + r.POST("/messages/:id/retract", func(c *gin.Context) { + msgID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var message Message + if err := db.First(&message, msgID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"}) + return + } + // One-tap undo (PRD.md §3.1, AGENTS.md "every automatic action needs + // post-hoc notification + one-tap undo") applies to unattended + // twin auto-sends -- a human retracting their own words is a + // different, unrelated feature this endpoint doesn't cover. + if message.SenderMode != SenderTwin { + c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored (auto-sent) messages can be retracted"}) + return + } + if message.Retracted { + c.JSON(http.StatusConflict, gin.H{"detail": "message already retracted"}) + return + } + + message.Retracted = true + if err := db.Save(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + relay.broadcast(message.ConversationID, gin.H{ + "type": "retraction", + "id": message.ID, + }) + + c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true}) + }) + r.POST("/conversations/:id/draft", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { @@ -346,6 +390,84 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel}) }) + r.POST("/users/:id/whitelist-rules", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + + var req createWhitelistRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + rule := WhitelistRule{UserID: userID, ContactID: req.ContactID, TopicKeyword: req.TopicKeyword} + if err := db.Create(&rule).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": rule.ID, + "user_id": rule.UserID, + "contact_id": rule.ContactID, + "topic_keyword": rule.TopicKeyword, + }) + }) + + r.GET("/users/:id/whitelist-rules", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user User + if err := db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + + var rules []WhitelistRule + db.Where("user_id = ?", userID).Order("id").Find(&rules) + + out := make([]gin.H, 0, len(rules)) + for _, rule := range rules { + out = append(out, gin.H{ + "id": rule.ID, + "contact_id": rule.ContactID, + "topic_keyword": rule.TopicKeyword, + }) + } + c.JSON(http.StatusOK, gin.H{"whitelist_rules": out}) + }) + + r.DELETE("/users/:id/whitelist-rules/:ruleId", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + ruleID, ok := parseUintParam(c, "ruleId") + if !ok { + return + } + + var rule WhitelistRule + if err := db.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "whitelist rule not found"}) + return + } + db.Delete(&rule) + + c.JSON(http.StatusOK, gin.H{"deleted": true}) + }) + r.DELETE("/users/:id", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 3e56a5e..5661934 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -639,6 +639,155 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { } } +func TestWhitelistRuleCRUD(t *testing.T) { + server, _ := setupTestServer(t) + userID := mustSignup(t, server.URL, "화이트") + base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules" + + createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"}) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode) + } + var created map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&created) + if created["topic_keyword"] != "저녁" || created["contact_id"] != nil { + t.Fatalf("unexpected created rule: %v", created) + } + ruleID := uint(created["id"].(float64)) + + listResp, err := http.Get(base) + if err != nil { + t.Fatalf("list rules: %v", err) + } + var list map[string]interface{} + json.NewDecoder(listResp.Body).Decode(&list) + rules := list["whitelist_rules"].([]interface{}) + if len(rules) != 1 { + t.Fatalf("expected 1 rule listed, got %d", len(rules)) + } + + delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil) + delResp, err := http.DefaultClient.Do(delReq) + if err != nil { + t.Fatalf("delete rule: %v", err) + } + if delResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode) + } + + listResp2, _ := http.Get(base) + var list2 map[string]interface{} + json.NewDecoder(listResp2.Body).Decode(&list2) + if len(list2["whitelist_rules"].([]interface{})) != 0 { + t.Fatalf("expected 0 rules after delete, got %v", list2) + } + + delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil) + delAgainResp, _ := http.DefaultClient.Do(delAgain) + if delAgainResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode) + } +} + +func TestWhitelistRuleCRUDMissingUser(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestRetractTwinMessage(t *testing.T) { + server, db := setupTestServer(t) + senderID := mustSignup(t, server.URL, "되돌리기") + setAutonomyLevel(t, server.URL, senderID, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" + + strconv.FormatUint(uint64(conv.ID), 10) + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial ws: %v", err) + } + defer ws.Close() + + sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin}) + if sendResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 auto-sending, got %d", sendResp.StatusCode) + } + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + var sendBroadcast map[string]interface{} + if err := ws.ReadJSON(&sendBroadcast); err != nil { + t.Fatalf("read send broadcast: %v", err) + } + if sendBroadcast["type"] != "message" { + t.Fatalf("expected type:message on send broadcast, got %v", sendBroadcast) + } + + retractResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if retractResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 retracting, got %d", retractResp.StatusCode) + } + + var retractBroadcast map[string]interface{} + if err := ws.ReadJSON(&retractBroadcast); err != nil { + t.Fatalf("read retract broadcast: %v", err) + } + if retractBroadcast["type"] != "retraction" || uint(retractBroadcast["id"].(float64)) != msgID { + t.Fatalf("unexpected retraction broadcast: %v", retractBroadcast) + } + + var message Message + db.First(&message, msgID) + if !message.Retracted { + t.Fatalf("expected message.Retracted true after retract") + } + + // Already retracted -- must not succeed again. + again := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if again.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 retracting an already-retracted message, got %d", again.StatusCode) + } +} + +func TestRetractRejectsHumanMessage(t *testing.T) { + server, db := setupTestServer(t) + senderID := mustSignup(t, server.URL, "사람") + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "안녕", SenderMode: SenderHuman, + }) + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 retracting a human message, got %d", resp.StatusCode) + } +} + +func TestRetractMissingMessage(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/messages/9999/retract", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/core-backend/models.go b/core-backend/models.go index 3a70df8..e5c850b 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -69,7 +69,12 @@ type Message struct { SenderID uint `gorm:"not null"` SenderMode SenderMode `gorm:"not null;default:human"` Text string `gorm:"not null"` - CreatedAt time.Time + // Retracted is the one-tap undo for an L2 auto-send (PRD.md §3.1, + // AGENTS.md "every automatic action needs post-hoc notification + + // one-tap undo") -- set via POST /messages/:id/retract, twin-authored + // messages only. + Retracted bool `gorm:"not null;default:false"` + CreatedAt time.Time } type TwinSettings struct { diff --git a/docs/roadmap.md b/docs/roadmap.md index 607f042..45ce129 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -59,7 +59,13 @@ `ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서 (Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고) - [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 -- [ ] 사후 알림 + 되돌리기 로그 스키마/API +- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그). + 되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 + + `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, + 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. + 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. + **UI는 아직** — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 건 Flutter 쪽. + 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) **2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수** - [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 @@ -140,8 +146,8 @@ 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, `POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로 - 완료(아래 5번과 함께 구현). 남은 건 온디바이스 말투 이력 저장, 사후 알림/되돌리기 로그 — 클라이언트 - 의존이라 2.3(Flutter) 대기 + 완료(아래 5번과 함께 구현). 되돌리기 API도 완료(아래 5번 참고). 남은 건 온디바이스 말투 이력 + 저장 — 클라이언트 의존이라 2.3(Flutter) 대기 4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: @@ -151,6 +157,9 @@ - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 + - 5-2. [x] 화이트리스트 규칙 CRUD API (`POST`/`GET /users/:id/whitelist-rules`, + `DELETE /users/:id/whitelist-rules/:ruleId`) — Flutter의 자율성 설정 화면(2.3)이 붙여 쓸 수 + 있게 미리 준비. `contact_id`는 저장되지만 매칭 로직은 여전히 전역 키워드만 봄(위 2.2 참고) 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만