자율성 상대별 예외(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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +80,12 @@ type Contact struct {
|
|||
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
|
||||
// "use the global default".
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -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 프롬프트에 전혀 전달되지 않음 — 저장만 되는
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final session = context.read<SessionState>();
|
||||
final myId = session.user?.id;
|
||||
RelationshipTier? tierOverride;
|
||||
AutonomyLevel? autonomyOverride;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -91,6 +92,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
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<ContactsScreen> {
|
|||
contactUserId: peer,
|
||||
relationshipNote: noteCtrl.text.trim(),
|
||||
relationshipTier: tierOverride,
|
||||
autonomyLevel: autonomyOverride,
|
||||
);
|
||||
setState(() {
|
||||
_contacts = [..._contacts, created];
|
||||
|
|
@ -163,6 +172,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
// PATCH는 전체 교체라 매번 이 값을 그대로 다시 보낸다 — 안 보내면(=이전
|
||||
// 코드처럼 필드 자체를 안 넣으면) 서버가 기존 오버라이드를 null로 되돌림.
|
||||
RelationshipTier? tierOverride = contact.relationshipTier;
|
||||
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -206,6 +216,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
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<ContactsScreen> {
|
|||
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<ContactsScreen> {
|
|||
: [
|
||||
'사용자 #${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<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,
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue