자율성 상대별 예외(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:
parent
4182eba9a6
commit
b4a3b4a903
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -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 프롬프트에 전혀 전달되지 않음 — 저장만 되는
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue