자율성 상대별 예외(2.7-D) 구현: 연락처별 AutonomyLevel 오버라이드 + 발송 게이트 교체

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
This commit is contained in:
Claude 2026-08-03 05:30:42 +00:00
parent 4182eba9a6
commit b4a3b4a903
No known key found for this signature in database
11 changed files with 430 additions and 24 deletions

View File

@ -21,6 +21,9 @@ type createContactRequest struct {
// RelationshipTier overrides the owner's global default for this // RelationshipTier overrides the owner's global default for this
// contact (roadmap.md §2.7-B). Nil = use the global default. // contact (roadmap.md §2.7-B). Nil = use the global default.
RelationshipTier *RelationshipTier `json:"relationship_tier"` 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 { type updateContactRequest struct {
@ -28,6 +31,7 @@ type updateContactRequest struct {
ContactUserID *uint `json:"contact_user_id"` ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"` RelationshipNote string `json:"relationship_note"`
RelationshipTier *RelationshipTier `json:"relationship_tier"` RelationshipTier *RelationshipTier `json:"relationship_tier"`
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
} }
func contactJSON(ct Contact) gin.H { func contactJSON(ct Contact) gin.H {
@ -37,6 +41,7 @@ func contactJSON(ct Contact) gin.H {
"contact_user_id": ct.ContactUserID, "contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote, "relationship_note": ct.RelationshipNote,
"relationship_tier": ct.RelationshipTier, "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"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return 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{ contact := Contact{
OwnerUserID: userID, OwnerUserID: userID,
ContactUserID: req.ContactUserID, ContactUserID: req.ContactUserID,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
RelationshipNote: req.RelationshipNote, RelationshipNote: req.RelationshipNote,
RelationshipTier: req.RelationshipTier, RelationshipTier: req.RelationshipTier,
AutonomyLevel: req.AutonomyLevel,
} }
if err := db.Create(&contact).Error; err != nil { if err := db.Create(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) 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"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return 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 var contact Contact
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil { 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"}) 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.ContactUserID = req.ContactUserID
contact.RelationshipNote = req.RelationshipNote contact.RelationshipNote = req.RelationshipNote
contact.RelationshipTier = req.RelationshipTier contact.RelationshipTier = req.RelationshipTier
contact.AutonomyLevel = req.AutonomyLevel
if err := db.Save(&contact).Error; err != nil { if err := db.Save(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return

View File

@ -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
}

View File

@ -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"])
}
}

View File

@ -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 // Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing
// settings fail closed to L0, the documented default. // settings fail closed to L0, the documented default. roadmap.md
level := AutonomyL0 // §2.7-D: the level itself can be overridden per-contact (1:1
var settings TwinSettings // only -- group conversations already returned above and never
if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil { // reach this point), so resolve it the same way relationship
level = settings.AutonomyLevel // tier is resolved instead of reading only the global default.
} level := resolveAutonomyLevel(db, req.SenderID, convID)
switch level { switch level {
case AutonomyL0: 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()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return 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"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return return
} }

View File

@ -17,6 +17,10 @@ const (
AutonomyL2 AutonomyLevel = "L2" 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 / // 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 // 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 // close friend than for someone you'd stay formal with. Defaults to the
@ -76,6 +80,12 @@ type Contact struct {
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means // for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
// "use the global default". // "use the global default".
RelationshipTier *RelationshipTier RelationshipTier *RelationshipTier
// 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 CreatedAt time.Time
} }

View File

@ -51,7 +51,13 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
안전 최소값 placeholder), `POST /conversations/:id/messages` 하드게이트에 peer-veto· 안전 최소값 placeholder), `POST /conversations/:id/messages` 하드게이트에 peer-veto·
그룹차단 다음 순서로 추가, `TwinDisabledByFlood` 대화방 플래그 + `POST 그룹차단 다음 순서로 추가, `TwinDisabledByFlood` 대화방 플래그 + `POST
/conversations/:id/flood-reset` one-tap undo, 기존 `EscalationLog`/`InboxScreen` /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 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 - 실 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-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-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-C3b** | 도배 중단 시 사후 알림 | **done** (2026-08-03) | 기존 `EscalationLog`/`InboxScreen` 그대로 재사용(신규 알림 경로 없음). 대화 목록·채팅방 배너에 상태 표시 + "자동응대 재개" one-tap undo 버튼 추가 |
| **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | todo | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) | | **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | **done** (2026-08-03) | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) |
| **N4-C4b** | 자율성 레벨 해석 함수 + 발송 게이트 교체 | todo | `resolveRelationshipTier`와 동일 구조, `main.go`의 전역값만 읽던 부분 교체 | | **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 | todo | `contacts_screen.dart`, `_RelationshipTierPicker` 옆에 자율성 피커 추가 | | **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-C5a** | `draftRequest``RelationshipNote` 필드 추가 | todo | `core-backend/aiservice.go` |
| **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | todo | `main.go` `POST /conversations/:id/draft`, 그룹은 스킵 | | **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | todo | `main.go` `POST /conversations/:id/draft`, 그룹은 스킵 |
| **N4-C5c** | 메모를 톤 프롬프트에 주입 | todo | `ai-service/app/generation.py`, 빈 값이면 무영향 | | **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` 라이브) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), 5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03),
~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done**
(2026-08-03) — **Track C 콘텐츠 갭 전체 완료.** 남은 것: **Master FCM 시크릿(N4-1/3)** (2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가
→ N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3는 아직 GitHub 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03). 남은 것: Track C5(관계 메모
`main`에만 있음) 반영)·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의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -193,15 +193,24 @@
**2.7-D 자율성 상대별 예외** (`PRD.md` §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 **2.7-D 자율성 상대별 예외** (`PRD.md` §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외
설정", §4 엣지케이스 "사용자가 여러 상대에게 다른 자율성 레벨을 원함" — P0인데 현재 전역 설정", §4 엣지케이스 "사용자가 여러 상대에게 다른 자율성 레벨을 원함" — P0인데 현재 전역
`TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견) `TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견 —
- [ ] `Contact``AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와 **완료** 2026-08-03)
동일 패턴, nil = 전역 기본값 사용) - [x] `Contact``AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와
- [ ] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier` 동일 패턴, nil = 전역 기본값 사용) — `core-backend/models.go`
와 동일 구조) — 메시지 발송 게이트(`main.go` `POST /conversations/:id/messages`)가 전역값만 - [x] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`
읽던 걸 이 해석 함수로 교체 와 동일 구조) — `core-backend/autonomy_resolve.go``resolveAutonomyLevel()`. 메시지
- [ ] 연락처 추가/수정 다이얼로그에 자율성 레벨 오버라이드 UI(`contacts_screen.dart`, 발송 게이트(`main.go` `POST /conversations/:id/messages`)가 `db.Where("user_id =
`_RelationshipTierPicker`와 나란히 `_AutonomyLevelPicker` 같은 위젯) ?", req.SenderID).First(&settings)`로 전역값만 읽던 걸 이 해석 함수 호출 한 줄로 교체 —
- [ ] 그룹 대화는 기존과 동일하게 항상 전역 L0 취급 유지(단톡 따라잡기 안전 불변식 변경 없음) 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는 이미 **2.7-E 관계 메모 실제 반영** (`PRD.md` §3.2 P1, `Contact.RelationshipNote` 필드·CRUD는 이미
있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는 있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는

View File

@ -4,6 +4,13 @@ enum SenderMode { human, twin }
// ignore: constant_identifier_names // ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 } 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. /// (roadmap.md §2.7-B, PRD.md §2.1-/§3.1) minimum 2 tiers.
enum RelationshipTier { enum RelationshipTier {
close, close,
@ -99,6 +106,7 @@ class Contact {
this.contactUserId, this.contactUserId,
this.relationshipNote = '', this.relationshipNote = '',
this.relationshipTier, this.relationshipTier,
this.autonomyLevel,
}); });
final int id; final int id;
@ -108,6 +116,10 @@ class Contact {
/// Per-contact override of the global relationship tier (roadmap.md /// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). Null means "use the global default". /// §2.7-B). Null means "use the global default".
final RelationshipTier? relationshipTier; 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<String, dynamic> json) => Contact( factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int, id: json['id'] as int,
@ -117,6 +129,12 @@ class Contact {
relationshipTier: json['relationship_tier'] == null relationshipTier: json['relationship_tier'] == null
? null ? null
: RelationshipTier.fromJson(json['relationship_tier'] as String?), : 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,
),
); );
} }

View File

@ -49,6 +49,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final myId = session.user?.id; final myId = session.user?.id;
RelationshipTier? tierOverride; RelationshipTier? tierOverride;
AutonomyLevel? autonomyOverride;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -91,6 +92,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
value: tierOverride, value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t), 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<ContactsScreen> {
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride, relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
); );
setState(() { setState(() {
_contacts = [..._contacts, created]; _contacts = [..._contacts, created];
@ -163,6 +172,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
// PATCH는 (= // PATCH는 (=
// ) null로 . // ) null로 .
RelationshipTier? tierOverride = contact.relationshipTier; RelationshipTier? tierOverride = contact.relationshipTier;
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -206,6 +216,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
value: tierOverride, value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t), 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<ContactsScreen> {
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride, relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
); );
setState(() { setState(() {
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
@ -358,6 +376,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
: [ : [
'사용자 #${c.contactUserId}', '사용자 #${c.contactUserId}',
if (c.relationshipTier != null) c.relationshipTier!.label, if (c.relationshipTier != null) c.relationshipTier!.label,
if (c.autonomyLevel != null) c.autonomyLevel!.label,
if (c.relationshipNote.isNotEmpty) c.relationshipNote, if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '), ].join(' · '),
style: theme.textTheme.bodySmall?.copyWith( 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<AutonomyLevel?> 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),
),
],
);
}
}

View File

@ -119,12 +119,14 @@ class ApiClient {
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier, RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async { }) async {
final json = await _json('POST', '/users/$userId/contacts', body: { final json = await _json('POST', '/users/$userId/contacts', body: {
'display_name': displayName, 'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId, if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote, 'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name, if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }
@ -136,12 +138,14 @@ class ApiClient {
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier, RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async { }) async {
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: { final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
'display_name': displayName, 'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId, if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote, 'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name, if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }

View File

@ -50,5 +50,28 @@ void main() {
'relationship_note': '대학', 'relationship_note': '대학',
}); });
expect(contact.contactUserId, 7); 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);
}); });
} }