From b4a3b4a903f9590f04c0a719bd49ab7c2b99dfef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 05:30:42 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=9C=A8=EC=84=B1=20=EC=83=81?= =?UTF-8?q?=EB=8C=80=EB=B3=84=20=EC=98=88=EC=99=B8(2.7-D)=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84:=20=EC=97=B0=EB=9D=BD=EC=B2=98=EB=B3=84=20AutonomyLev?= =?UTF-8?q?el=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20+=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD.md §3.1 P0 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정" / §4 엣지케이스 대비 미구현 갭 해소. 관계별 페르소나(2.7-B)의 RelationshipTier 오버라이드 패턴을 그대로 미러링: - core-backend/models.go: Contact.AutonomyLevel *AutonomyLevel 추가(nil = 전역 기본값), validAutonomyLevel() 검증 헬퍼 추가(twin-settings PATCH의 인라인 검증도 이 헬퍼로 통일) - core-backend/autonomy_resolve.go: resolveAutonomyLevel() 신규 — 연락처 오버라이드 (1:1 전용) → 전역 TwinSettings 기본값 → L0 순으로 해석. L1/L2가 아니라 L0으로 폴백하는 이유는 이 코드베이스 전반의 안전 우선 기본값과 동일(불확실하면 항상 초안만 생성, 사람이 직접 발송). 그룹 대화는 RelationshipTier와 동일하게 항상 전역 기본값만 사용 — main.go의 그룹 대화 무조건 차단과 이중으로 안전 - core-backend/main.go: POST /conversations/:id/messages의 자율성 게이트가 TwinSettings 전역값만 읽던 걸 resolveAutonomyLevel() 호출로 교체. peer-veto→그룹 차단→도배 감지→에스컬레이션 순서와 각 하드게이트는 그대로 유지, "level" 계산 방식만 바뀜 - core-backend/a1_a2_routes.go: createContactRequest/updateContactRequest에 AutonomyLevel 필드 추가, contactJSON()에 포함, 생성/수정 핸들러가 RelationshipTier와 동일한 전체 교체(full-replace) 시맨틱으로 처리(PATCH에서 필드 생략 시 nil로 리셋) - core-backend/autonomy_resolve_test.go: 연락처 오버라이드 우선순위, 전역 기본값 폴백, 그룹 대화는 오버라이드 무시, 잘못된 값 검증 거부, PATCH 전체 교체 리셋 커버 - mobile: models.dart에 Contact.autonomyLevel(nullable) 추가, api_client.dart createContact/updateContact에 선택적 autonomyLevel 파라미터 스레딩, contacts_screen.dart에 _AutonomyLevelPicker(_RelationshipTierPicker와 동일 구조) 추가해 추가/수정 다이얼로그에 배치 + 연락처 목록 서브타이틀에 표시 ai-service는 변경 없음 — 자율성 레벨은 발송 게이트 로직일 뿐 초안 톤에 영향을 주지 않아 ai-service 프롬프트까지 전달될 필요가 없음. 테스트: go test ./... 61개 전부 PASS, flutter analyze/test 클린(기존 무관 info 린트 1건 제외). docs/roadmap.md §2.7-D, docs/deploy-checklist.md N4-C4a/b/c를 done으로 갱신하고 NOW/바로 다음 5개 요약도 동기화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m --- core-backend/a1_a2_routes.go | 15 ++ core-backend/autonomy_resolve.go | 42 +++++ core-backend/autonomy_resolve_test.go | 229 ++++++++++++++++++++++++ core-backend/main.go | 14 +- core-backend/models.go | 12 +- docs/deploy-checklist.md | 21 ++- docs/roadmap.md | 27 ++- mobile/lib/models/models.dart | 18 ++ mobile/lib/screens/contacts_screen.dart | 49 +++++ mobile/lib/services/api_client.dart | 4 + mobile/test/models_test.dart | 23 +++ 11 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 core-backend/autonomy_resolve.go create mode 100644 core-backend/autonomy_resolve_test.go diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index b34d05b..87b4b86 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -21,6 +21,9 @@ type createContactRequest struct { // RelationshipTier overrides the owner's global default for this // contact (roadmap.md §2.7-B). Nil = use the global default. RelationshipTier *RelationshipTier `json:"relationship_tier"` + // AutonomyLevel overrides the owner's global default autonomy level for + // this contact (roadmap.md §2.7-D). Nil = use the global default. + AutonomyLevel *AutonomyLevel `json:"autonomy_level"` } type updateContactRequest struct { @@ -28,6 +31,7 @@ type updateContactRequest struct { ContactUserID *uint `json:"contact_user_id"` RelationshipNote string `json:"relationship_note"` RelationshipTier *RelationshipTier `json:"relationship_tier"` + AutonomyLevel *AutonomyLevel `json:"autonomy_level"` } func contactJSON(ct Contact) gin.H { @@ -37,6 +41,7 @@ func contactJSON(ct Contact) gin.H { "contact_user_id": ct.ContactUserID, "relationship_note": ct.RelationshipNote, "relationship_tier": ct.RelationshipTier, + "autonomy_level": ct.AutonomyLevel, } } @@ -270,12 +275,17 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"}) return } + if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) { + c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"}) + return + } contact := Contact{ OwnerUserID: userID, ContactUserID: req.ContactUserID, DisplayName: req.DisplayName, RelationshipNote: req.RelationshipNote, RelationshipTier: req.RelationshipTier, + AutonomyLevel: req.AutonomyLevel, } if err := db.Create(&contact).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) @@ -322,6 +332,10 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"}) return } + if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) { + c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"}) + return + } var contact Contact if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"}) @@ -335,6 +349,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { contact.ContactUserID = req.ContactUserID contact.RelationshipNote = req.RelationshipNote contact.RelationshipTier = req.RelationshipTier + contact.AutonomyLevel = req.AutonomyLevel if err := db.Save(&contact).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return diff --git a/core-backend/autonomy_resolve.go b/core-backend/autonomy_resolve.go new file mode 100644 index 0000000..2645f64 --- /dev/null +++ b/core-backend/autonomy_resolve.go @@ -0,0 +1,42 @@ +package main + +import "gorm.io/gorm" + +// resolveAutonomyLevel implements roadmap.md §2.7-D: a user may want L2 +// (auto-send) with a close friend but L0 (drafts only) with someone else -- +// PRD.md §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정" and §4 +// "사용자가 여러 상대에게 다른 자율성 레벨을 원함". Mirrors +// resolveRelationshipTier's structure exactly. Resolution order: +// contact-specific override (1:1 only) -> the sender's global TwinSettings +// default -> AutonomyL0 as the fail-safe fallback if nothing is set (L0 is +// the documented safe default everywhere else in this codebase -- never +// fail open to L1/L2). Group conversations always use the global default, +// same as relationship tier, since a group has more than one counterpart to +// pick a level for -- note this resolver only decides the L0/L1/L2 branch; +// the unconditional "group conversations never auto-send" hard block in +// main.go's POST /conversations/:id/messages runs earlier and independent +// of this function's result. +func resolveAutonomyLevel(db *gorm.DB, actorID, conversationID uint) AutonomyLevel { + var conv Conversation + if err := db.First(&conv, conversationID).Error; err == nil && !conv.IsGroup { + var parts []ConversationParticipant + db.Where("conversation_id = ?", conversationID).Find(&parts) + for _, p := range parts { + if p.UserID == actorID { + continue + } + var contact Contact + err := db.Where("owner_user_id = ? AND contact_user_id = ?", actorID, p.UserID).First(&contact).Error + if err == nil && contact.AutonomyLevel != nil && validAutonomyLevel(*contact.AutonomyLevel) { + return *contact.AutonomyLevel + } + break + } + } + + var settings TwinSettings + if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validAutonomyLevel(settings.AutonomyLevel) { + return settings.AutonomyLevel + } + return AutonomyL0 +} diff --git a/core-backend/autonomy_resolve_test.go b/core-backend/autonomy_resolve_test.go new file mode 100644 index 0000000..a56384e --- /dev/null +++ b/core-backend/autonomy_resolve_test.go @@ -0,0 +1,229 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +// TestContactAutonomyLevelOverridesGlobalDefaultInSendGate mirrors +// TestContactRelationshipTierOverridesGlobalDefaultInDraft: global default +// stays at the signup default (L0, which blocks all twin auto-send), but a +// per-contact override raises it to L2 for this specific counterpart, so a +// whitelisted twin message goes through without requiring approval. +func TestContactAutonomyLevelOverridesGlobalDefaultInSendGate(t *testing.T) { + server, db := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + l2 := AutonomyL2 + contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + AutonomyLevel: &l2, + }) + if contactResp.StatusCode != http.StatusOK { + t.Fatalf("create contact: %d", contactResp.StatusCode) + } + + db.Create(&WhitelistRule{UserID: ownerID, TopicKeyword: "ㅇㅇ"}) + + convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{ + UserIDs: []uint{ownerID, peerID}, + }) + var conv map[string]interface{} + json.NewDecoder(convResp.Body).Decode(&conv) + convID := uint(conv["id"].(float64)) + + // Global default is still L0 (never changed) -- without the contact + // override this would 403. Approved is false and Text matches the + // whitelist keyword, which only clears the gate at L2. + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: ownerID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 (contact override L2 + whitelist match), got %d", resp.StatusCode) + } +} + +// TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride mirrors +// TestDraftFallsBackToGlobalTierWithoutContactOverride: no contact record at +// all -- resolveAutonomyLevel must fall back to the sender's global +// TwinSettings.AutonomyLevel rather than erroring or defaulting to L0 when a +// non-L0 global level was explicitly set. +func TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL1) + + convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{ + UserIDs: []uint{ownerID, peerID}, + }) + var conv map[string]interface{} + json.NewDecoder(convResp.Body).Decode(&conv) + convID := uint(conv["id"].(float64)) + + // L1 without approval must still block. + blocked := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin, + }) + if blocked.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for unapproved L1 send, got %d", blocked.StatusCode) + } + + // L1 with approval must go through -- confirms the global default (not + // some stray L0 fallback) is really what got resolved. + approved := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin, Approved: true, + }) + if approved.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for approved L1 send using global default, got %d", approved.StatusCode) + } +} + +// TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride +// verifies resolveAutonomyLevel's own group-conversation branch directly +// (the unconditional "no twin auto-send in groups" hard block in main.go +// already stops group sends earlier and independently -- this test is about +// resolveAutonomyLevel's resolution order, mirroring +// TestGroupConversationDraftUsesGlobalTierNotContactOverride). +func TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride(t *testing.T) { + server, db := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + l2 := AutonomyL2 + postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + AutonomyLevel: &l2, + }) + + convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID}) + + // Global default is still the signup default (L0). Even though the + // contact has an L2 override, a group conversation must never pick it up. + got := resolveAutonomyLevel(db, ownerID, convID) + if got != AutonomyL0 { + t.Fatalf("expected group conversation to resolve global default L0 ignoring contact override, got %v", got) + } +} + +// TestDraftGroupConversationHardBlockStillWinsOverAutonomyLevel is a smoke +// check that the pre-existing "no twin auto-send in groups" hard block +// (roadmap.md §2.7-A) still runs before -- and independent of -- the +// autonomy gate, even when a contact override would otherwise allow L2. +func TestGroupConversationSendStillBlockedRegardlessOfContactAutonomyOverride(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + l2 := AutonomyL2 + postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + AutonomyLevel: &l2, + }) + + convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID}) + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: ownerID, Text: "그룹에서 자동발송 시도", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for group twin auto-send regardless of contact autonomy override, got %d", resp.StatusCode) + } +} + +func TestResolveAutonomyLevelFallsBackToL0WithNoSettingsAtAll(t *testing.T) { + server, db := setupTestServer(t) + // A conversation with a sender that never signed up / has no + // TwinSettings row at all must fail closed to L0, not error out. + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + _ = server + + got := resolveAutonomyLevel(db, 999999, conv.ID) + if got != AutonomyL0 { + t.Fatalf("expected fail-safe default L0 for unknown user, got %v", got) + } +} + +func TestCreateContactRejectsInvalidAutonomyLevel(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + + bad := AutonomyLevel("L9") + resp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "이상함", + AutonomyLevel: &bad, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 creating contact with invalid autonomy_level, got %d", resp.StatusCode) + } +} + +func TestUpdateContactRejectsInvalidAutonomyLevel(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + + createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "친구", + }) + var created map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&created) + contactID := uint(created["id"].(float64)) + + bad := AutonomyLevel("nope") + resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{ + DisplayName: "친구", + AutonomyLevel: &bad, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 updating contact with invalid autonomy_level, got %d", resp.StatusCode) + } +} + +// TestUpdateContactFullReplaceResetsAutonomyLevelOverride confirms Contact +// PATCH is full-replace (like every other Contact field, unlike TwinSettings +// PATCH which is conditional/partial) -- omitting autonomy_level on a later +// PATCH resets the override back to nil (use the global default), it does +// not leave the previous override in place. +func TestUpdateContactFullReplaceResetsAutonomyLevelOverride(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + l2 := AutonomyL2 + createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + AutonomyLevel: &l2, + }) + var created map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&created) + contactID := uint(created["id"].(float64)) + if created["autonomy_level"] != "L2" { + t.Fatalf("expected autonomy_level L2 right after create, got %v", created["autonomy_level"]) + } + + // Update without autonomy_level in the request body -- full-replace + // semantics mean this must reset it to nil, exactly like + // relationship_tier does today. + updateResp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + }) + if updateResp.StatusCode != http.StatusOK { + t.Fatalf("update contact: %d", updateResp.StatusCode) + } + var updated map[string]interface{} + json.NewDecoder(updateResp.Body).Decode(&updated) + if updated["autonomy_level"] != nil { + t.Fatalf("expected autonomy_level to reset to nil after omitting it on PATCH, got %v", updated["autonomy_level"]) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index 8153d94..cb9ce70 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -338,12 +338,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } // Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing - // settings fail closed to L0, the documented default. - level := AutonomyL0 - var settings TwinSettings - if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil { - level = settings.AutonomyLevel - } + // settings fail closed to L0, the documented default. roadmap.md + // §2.7-D: the level itself can be overridden per-contact (1:1 + // only -- group conversations already returned above and never + // reach this point), so resolve it the same way relationship + // tier is resolved instead of reading only the global default. + level := resolveAutonomyLevel(db, req.SenderID, convID) switch level { case AutonomyL0: @@ -543,7 +543,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } - if req.AutonomyLevel != AutonomyL0 && req.AutonomyLevel != AutonomyL1 && req.AutonomyLevel != AutonomyL2 { + if !validAutonomyLevel(req.AutonomyLevel) { c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"}) return } diff --git a/core-backend/models.go b/core-backend/models.go index 56319fa..1650641 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -17,6 +17,10 @@ const ( AutonomyL2 AutonomyLevel = "L2" ) +func validAutonomyLevel(l AutonomyLevel) bool { + return l == AutonomyL0 || l == AutonomyL1 || l == AutonomyL2 +} + // RelationshipTier is the minimum-2-tier persona split roadmap.md §2.7-B / // PRD.md §2.1-②/§3.1 requires: draft tone should read differently for a // close friend than for someone you'd stay formal with. Defaults to the @@ -76,7 +80,13 @@ type Contact struct { // for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means // "use the global default". RelationshipTier *RelationshipTier - CreatedAt time.Time + // AutonomyLevel overrides the owner's global TwinSettings.AutonomyLevel + // for auto-send gating in 1:1 conversations with this specific contact + // (roadmap.md §2.7-D, PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Nil + // means "use the global default". Same nullable-override shape as + // RelationshipTier above. + AutonomyLevel *AutonomyLevel + CreatedAt time.Time } // TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위 diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index f028025..5fb9012 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -51,7 +51,13 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. 안전 최소값 placeholder), `POST /conversations/:id/messages` 하드게이트에 peer-veto· 그룹차단 다음 순서로 추가, `TwinDisabledByFlood` 대화방 플래그 + `POST /conversations/:id/flood-reset` one-tap undo, 기존 `EscalationLog`/`InboxScreen` - 재사용 + 대화 목록/채팅방 배너에 상태·재개 버튼 노출. **Track C 콘텐츠 갭 전체(A/B/C) 완료.** + 재사용 + 대화 목록/채팅방 배너에 상태·재개 버튼 노출. **Track C 콘텐츠 갭 A/B/C 전체 완료.** + 2026-08-03 2차 재분석으로 D(자율성 상대별 예외)/E(관계 메모 반영)/F(답장 마감 알림) 추가 + 발견 — C4(자율성 상대별 예외)도 **완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 + 미배포) — `Contact.AutonomyLevel` 오버라이드 필드 + `resolveAutonomyLevel()`(연락처 + 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조)로 `POST + /conversations/:id/messages`의 자율성 게이트 교체, `contacts_screen.dart`에 + `_AutonomyLevelPicker` 추가. C5(관계 메모 반영)·C6(답장 마감 알림)은 아직 todo. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 @@ -204,9 +210,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) | | **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | **done** (2026-08-03) | `core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`, 안전 최소값 placeholder) + `main.go` `POST /conversations/:id/messages`의 peer-veto·그룹차단 다음, 에스컬레이션 이전 지점(우회 불가). `Conversation.TwinDisabledByFlood`로 대화방 단위 차단, `POST /conversations/:id/flood-reset`로 재개(거부권과 달리 되돌리기 가능) | | **N4-C3b** | 도배 중단 시 사후 알림 | **done** (2026-08-03) | 기존 `EscalationLog`/`InboxScreen` 그대로 재사용(신규 알림 경로 없음). 대화 목록·채팅방 배너에 상태 표시 + "자동응대 재개" one-tap undo 버튼 추가 | -| **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | todo | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) | -| **N4-C4b** | 자율성 레벨 해석 함수 + 발송 게이트 교체 | todo | `resolveRelationshipTier`와 동일 구조, `main.go`의 전역값만 읽던 부분 교체 | -| **N4-C4c** | 연락처별 자율성 오버라이드 UI | todo | `contacts_screen.dart`, `_RelationshipTierPicker` 옆에 자율성 피커 추가 | +| **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | **done** (2026-08-03) | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) | +| **N4-C4b** | 자율성 레벨 해석 함수 + 발송 게이트 교체 | **done** (2026-08-03) | `core-backend/autonomy_resolve.go` `resolveAutonomyLevel()`(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조). `main.go` `POST /conversations/:id/messages`가 전역값만 읽던 부분을 이 함수 호출로 교체 — peer-veto·그룹차단·도배 감지·에스컬레이션 순서는 그대로, "level" 계산만 교체. 그룹 대화는 이 함수 자체도 전역 기본값만 쓰고, 그 전에 걸리는 무조건 차단과 이중으로 안전 | +| **N4-C4c** | 연락처별 자율성 오버라이드 UI | **done** (2026-08-03) | `contacts_screen.dart` `_AutonomyLevelPicker`(`_RelationshipTierPicker` 옆, 기본값 사용/L0/L1/L2 4-way 칩), 연락처 목록 서브타이틀에도 표시 | | **N4-C5a** | `draftRequest`에 `RelationshipNote` 필드 추가 | todo | `core-backend/aiservice.go` | | **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | todo | `main.go` `POST /conversations/:id/draft`, 그룹은 스킵 | | **N4-C5c** | 메모를 톤 프롬프트에 주입 | todo | `ai-service/app/generation.py`, 빈 값이면 무영향 | @@ -273,9 +279,10 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) 5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** - (2026-08-03) — **Track C 콘텐츠 갭 전체 완료.** 남은 것: **Master FCM 시크릿(N4-1/3)** - → N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3는 아직 GitHub - `main`에만 있음) + (2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가 + 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03). 남은 것: Track C5(관계 메모 + 반영)·C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA + (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4는 아직 GitHub `main`에만 있음) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9bc5a9a..3a67e03 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -193,15 +193,24 @@ **2.7-D 자율성 상대별 예외** (`PRD.md` §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정", §4 엣지케이스 "사용자가 여러 상대에게 다른 자율성 레벨을 원함" — P0인데 현재 전역 -`TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견) -- [ ] `Contact`에 `AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와 - 동일 패턴, nil = 전역 기본값 사용) -- [ ] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier` - 와 동일 구조) — 메시지 발송 게이트(`main.go` `POST /conversations/:id/messages`)가 전역값만 - 읽던 걸 이 해석 함수로 교체 -- [ ] 연락처 추가/수정 다이얼로그에 자율성 레벨 오버라이드 UI(`contacts_screen.dart`, - `_RelationshipTierPicker`와 나란히 `_AutonomyLevelPicker` 같은 위젯) -- [ ] 그룹 대화는 기존과 동일하게 항상 전역 L0 취급 유지(단톡 따라잡기 안전 불변식 변경 없음) +`TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견 — +**완료** 2026-08-03) +- [x] `Contact`에 `AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와 + 동일 패턴, nil = 전역 기본값 사용) — `core-backend/models.go` +- [x] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier` + 와 동일 구조) — `core-backend/autonomy_resolve.go`의 `resolveAutonomyLevel()`. 메시지 + 발송 게이트(`main.go` `POST /conversations/:id/messages`)가 `db.Where("user_id = + ?", req.SenderID).First(&settings)`로 전역값만 읽던 걸 이 해석 함수 호출 한 줄로 교체 — + peer-veto·그룹 차단·도배 감지·에스컬레이션 하드게이트는 순서 그대로 유지, "level" 계산 + 방식만 바뀜. 실패 시 폴백은 `L1`/`L2`가 아니라 `L0`(이 코드베이스 전반의 안전 우선 기본값과 + 동일한 이유 — 알 수 없으면 항상 초안만 만들고 사람이 직접 보냄) +- [x] 연락처 추가/수정 다이얼로그에 자율성 레벨 오버라이드 UI(`contacts_screen.dart`, + `_RelationshipTierPicker`와 나란히 `_AutonomyLevelPicker`(기본값 사용/L0/L1/L2 4-way 칩) 추가, + 연락처 목록 서브타이틀에도 오버라이드 표시) +- [x] 그룹 대화는 기존과 동일하게 항상 전역 L0 취급 유지(단톡 따라잡기 안전 불변식 변경 없음) — + `resolveAutonomyLevel()`도 `RelationshipTier`와 동일하게 그룹 대화면 연락처 오버라이드를 + 건너뛰고 전역 기본값만 사용하도록 구현, 그룹 대화는 애초에 `main.go`의 무조건 차단이 이 + 해석 함수 호출보다 먼저 걸려서 두 안전장치가 이중으로 겹침 **2.7-E 관계 메모 실제 반영** (`PRD.md` §3.2 P1, `Contact.RelationshipNote` 필드·CRUD는 이미 있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는 diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index 565503a..250876d 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -4,6 +4,13 @@ enum SenderMode { human, twin } // ignore: constant_identifier_names enum AutonomyLevel { L0, L1, L2 } +extension AutonomyLevelLabel on AutonomyLevel { + /// Short label reused verbatim from autonomy_settings_screen.dart's + /// segmented-button labels ("L0"/"L1"/"L2") so per-contact chips + /// (roadmap.md §2.7-D) don't invent new copy. + String get label => name; +} + /// 관계별 페르소나 (roadmap.md §2.7-B, PRD.md §2.1-②/§3.1) — minimum 2 tiers. enum RelationshipTier { close, @@ -99,6 +106,7 @@ class Contact { this.contactUserId, this.relationshipNote = '', this.relationshipTier, + this.autonomyLevel, }); final int id; @@ -108,6 +116,10 @@ class Contact { /// Per-contact override of the global relationship tier (roadmap.md /// §2.7-B). Null means "use the global default". final RelationshipTier? relationshipTier; + /// Per-contact override of the global autonomy level (roadmap.md §2.7-D, + /// PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Null means "use the + /// global default". + final AutonomyLevel? autonomyLevel; factory Contact.fromJson(Map json) => Contact( id: json['id'] as int, @@ -117,6 +129,12 @@ class Contact { relationshipTier: json['relationship_tier'] == null ? null : RelationshipTier.fromJson(json['relationship_tier'] as String?), + autonomyLevel: json['autonomy_level'] == null + ? null + : AutonomyLevel.values.firstWhere( + (e) => e.name == json['autonomy_level'] as String?, + orElse: () => AutonomyLevel.L0, + ), ); } diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index b49b459..8ca2a10 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -49,6 +49,7 @@ class _ContactsScreenState extends State { final session = context.read(); final myId = session.user?.id; RelationshipTier? tierOverride; + AutonomyLevel? autonomyOverride; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -91,6 +92,13 @@ class _ContactsScreenState extends State { value: tierOverride, onChanged: (t) => setDialogState(() => tierOverride = t), ), + const SizedBox(height: 12), + Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall), + const SizedBox(height: 6), + _AutonomyLevelPicker( + value: autonomyOverride, + onChanged: (l) => setDialogState(() => autonomyOverride = l), + ), ], ), ), @@ -120,6 +128,7 @@ class _ContactsScreenState extends State { contactUserId: peer, relationshipNote: noteCtrl.text.trim(), relationshipTier: tierOverride, + autonomyLevel: autonomyOverride, ); setState(() { _contacts = [..._contacts, created]; @@ -163,6 +172,7 @@ class _ContactsScreenState extends State { // PATCH는 전체 교체라 매번 이 값을 그대로 다시 보낸다 — 안 보내면(=이전 // 코드처럼 필드 자체를 안 넣으면) 서버가 기존 오버라이드를 null로 되돌림. RelationshipTier? tierOverride = contact.relationshipTier; + AutonomyLevel? autonomyOverride = contact.autonomyLevel; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -206,6 +216,13 @@ class _ContactsScreenState extends State { value: tierOverride, onChanged: (t) => setDialogState(() => tierOverride = t), ), + const SizedBox(height: 12), + Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall), + const SizedBox(height: 6), + _AutonomyLevelPicker( + value: autonomyOverride, + onChanged: (l) => setDialogState(() => autonomyOverride = l), + ), ], ), ), @@ -236,6 +253,7 @@ class _ContactsScreenState extends State { contactUserId: peer, relationshipNote: noteCtrl.text.trim(), relationshipTier: tierOverride, + autonomyLevel: autonomyOverride, ); setState(() { _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); @@ -358,6 +376,7 @@ class _ContactsScreenState extends State { : [ '사용자 #${c.contactUserId}', if (c.relationshipTier != null) c.relationshipTier!.label, + if (c.autonomyLevel != null) c.autonomyLevel!.label, if (c.relationshipNote.isNotEmpty) c.relationshipNote, ].join(' · '), style: theme.textTheme.bodySmall?.copyWith( @@ -435,3 +454,33 @@ class _RelationshipTierPicker extends StatelessWidget { ); } } + +/// Per-contact override of the global autonomy level (roadmap.md §2.7-D). +/// `value: null` means "use the global default set in 자율성 설정". +/// Mirrors _RelationshipTierPicker's structure exactly. +class _AutonomyLevelPicker extends StatelessWidget { + const _AutonomyLevelPicker({required this.value, required this.onChanged}); + + final AutonomyLevel? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: const Text('기본값 사용'), + selected: value == null, + onSelected: (_) => onChanged(null), + ), + for (final level in AutonomyLevel.values) + ChoiceChip( + label: Text(level.label), + selected: value == level, + onSelected: (_) => onChanged(level), + ), + ], + ); + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 9a2f9a6..fb7a203 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -119,12 +119,14 @@ class ApiClient { int? contactUserId, String relationshipNote = '', RelationshipTier? relationshipTier, + AutonomyLevel? autonomyLevel, }) async { final json = await _json('POST', '/users/$userId/contacts', body: { 'display_name': displayName, if (contactUserId != null) 'contact_user_id': contactUserId, 'relationship_note': relationshipNote, if (relationshipTier != null) 'relationship_tier': relationshipTier.name, + if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name, }); return Contact.fromJson(json); } @@ -136,12 +138,14 @@ class ApiClient { int? contactUserId, String relationshipNote = '', RelationshipTier? relationshipTier, + AutonomyLevel? autonomyLevel, }) async { final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: { 'display_name': displayName, if (contactUserId != null) 'contact_user_id': contactUserId, 'relationship_note': relationshipNote, if (relationshipTier != null) 'relationship_tier': relationshipTier.name, + if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name, }); return Contact.fromJson(json); } diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart index ffb9f6b..c983f7d 100644 --- a/mobile/test/models_test.dart +++ b/mobile/test/models_test.dart @@ -50,5 +50,28 @@ void main() { 'relationship_note': '대학', }); expect(contact.contactUserId, 7); + expect(contact.relationshipTier, isNull); + expect(contact.autonomyLevel, isNull); + }); + + // roadmap.md §2.7-D: Contact.autonomyLevel is a nullable per-contact + // override of the global autonomy level, parsed only when present -- + // mirrors relationshipTier's null-safe parsing above. + test('Contact parses autonomy_level override when present', () { + final withOverride = Contact.fromJson({ + 'id': 2, + 'display_name': '친구2', + 'contact_user_id': 8, + 'autonomy_level': 'L2', + }); + expect(withOverride.autonomyLevel, AutonomyLevel.L2); + expect(withOverride.autonomyLevel!.label, 'L2'); + + final withoutOverride = Contact.fromJson({ + 'id': 3, + 'display_name': '친구3', + 'contact_user_id': 9, + }); + expect(withoutOverride.autonomyLevel, isNull); }); }