관계별 페르소나(2.7-B) 구현: 관계 티어 필드 + 온보딩/연락처 UI + 톤 프롬프트 분기

PRD.md §2.1-②·§3.1 P0 갭 우선순위 #2. close/formal 2종 관계 티어를 추가하고,
초안 생성 시 상대별 톤을 다르게 낸다.

- core-backend: TwinSettings.RelationshipTier(전역 기본값, 안전 우선 formal
  기본), Contact.RelationshipTier(1:1 연락처별 오버라이드, nullable).
  persona.go의 resolveRelationshipTier()가 연락처 오버라이드 → 전역 기본값 →
  formal 순으로 해석하고, 그룹 대화는 상대가 여럿이라 항상 전역 기본값만 사용.
  POST /conversations/:id/draft는 기존에 인증을 요구하지 않던 동작을 깨지
  않도록 currentUser(..., false)로 선택적 인증 처리 후 티어를 주입.
- 안전 관련 부수 수정: 그룹 대화에서는 전역 자율성 레벨(L1/L2)과 무관하게
  와카뷰 자동 발송을 무조건 차단(단톡 따라잡기는 L0 고정이 맞음).
- ai-service: RELATIONSHIP_TIER_INSTRUCTIONS + system_prompt_for_tier()로
  Gemini system_instruction에 관계 톤 지침을 주입. 에스컬레이션/정체성 게이팅
  로직은 티어와 무관하게 그대로 유지.
- mobile: 온보딩(말투 샘플 다음 단계)과 자율성 설정 화면에 전역 기본값
  SegmentedButton, 연락처 추가/수정 다이얼로그에 _RelationshipTierPicker로
  상대별 오버라이드 추가.
- 테스트: core-backend persona_test.go 6개(해석 순서·그룹 예외·비인증
  기본값 포함), ai-service 6개(system_prompt_for_tier + draft_reply 경로)
  전부 추가, 기존 스위트 모두 통과(go test, pytest 47/47, flutter analyze/test).
  실제 Flutter 빌드 + Playwright로 온보딩→연락처 오버라이드→목록 표시까지
  전체 라운드트립 시각 검증 완료.
- docs: roadmap.md §2.7-B, deploy-checklist.md N4-C2a~d 완료 처리. 다음
  우선순위는 Track C3(스팸/도배 감지 최소 버전).

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 04:59:34 +00:00
parent 0d848bcb9a
commit 5733ec4399
No known key found for this signature in database
19 changed files with 663 additions and 114 deletions

View File

@ -47,6 +47,21 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" 내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
# 관계별 페르소나 (roadmap.md §2.7-B, PRD.md §2.1-②/§3.1) -- 최소 2종. 말투
# "예시"가 이미 있는 격식 수준을 담고 있으므로, 이 지침은 그 예시를 뒤집지
# 않는 선에서 미묘하게 톤을 조정하는 보조 신호일 뿐이다.
RELATIONSHIP_TIER_INSTRUCTIONS = {
"close": "\n\n[관계] 상대는 나와 가까운 사이다. 말투 예시가 허용하는 범위에서 편하고 친근하게 써라.",
"formal": "\n\n[관계] 상대는 나와 공식적인 사이다. 말투 예시의 격식 수준을 지키면서, "
"이모티콘/줄임말은 예시에 없다면 새로 만들지 말고 평소보다 한 톤 더 예의를 갖춰라.",
}
def system_prompt_for_tier(relationship_tier=None):
extra = RELATIONSHIP_TIER_INSTRUCTIONS.get(relationship_tier, "")
return SYSTEM_PROMPT + extra
def build_user_prompt(style_examples, context_lines): def build_user_prompt(style_examples, context_lines):
examples = "\n".join(f"- {s}" for s in style_examples) examples = "\n".join(f"- {s}" for s in style_examples)
context = "\n".join(context_lines) context = "\n".join(context_lines)
@ -61,12 +76,15 @@ def last_incoming_text(context_lines):
return last.split(": ", 1)[1] if ": " in last else last return last.split(": ", 1)[1] if ": " in last else last
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None): def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None, relationship_tier=None):
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok". """Returns (status, text). status is one of "escalate" | "no_key" | "ok".
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft. "ok": text is the generated draft.
relationship_tier: "close" | "formal" | None (roadmap.md §2.7-B). Only
nudges tone -- escalation/identity gating above is unaffected by it.
""" """
incoming = last_incoming_text(context_lines) incoming = last_incoming_text(context_lines)
gate = check_escalation(incoming) gate = check_escalation(incoming)
@ -89,6 +107,8 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key
resp = client.models.generate_content( resp = client.models.generate_content(
model=model, model=model,
contents=build_user_prompt(style_examples, context_lines), contents=build_user_prompt(style_examples, context_lines),
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300), config=types.GenerateContentConfig(
system_instruction=system_prompt_for_tier(relationship_tier), max_output_tokens=300
),
) )
return "ok", resp.text.strip() return "ok", resp.text.strip()

View File

@ -30,6 +30,7 @@ class DraftRequest(BaseModel):
history: Optional[List[str]] = None history: Optional[List[str]] = None
k: int = 6 k: int = 6
model: str = "gemini-2.5-flash" model: str = "gemini-2.5-flash"
relationship_tier: Optional[str] = None
@model_validator(mode="after") @model_validator(mode="after")
def check_exactly_one_style_source(self): def check_exactly_one_style_source(self):
@ -71,7 +72,9 @@ def draft(req: DraftRequest):
req.history, last_incoming_text(req.context_lines), k=req.k req.history, last_incoming_text(req.context_lines), k=req.k
) )
status, text = draft_reply(style_examples, req.context_lines, model=req.model) status, text = draft_reply(
style_examples, req.context_lines, model=req.model, relationship_tier=req.relationship_tier
)
return DraftResponse(status=status, text=text) return DraftResponse(status=status, text=text)

View File

@ -6,7 +6,14 @@ import sys
import types import types
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app.generation import build_user_prompt, draft_reply, last_incoming_text from app.generation import (
RELATIONSHIP_TIER_INSTRUCTIONS,
SYSTEM_PROMPT,
build_user_prompt,
draft_reply,
last_incoming_text,
system_prompt_for_tier,
)
def test_last_incoming_text_strips_speaker_prefix(): def test_last_incoming_text_strips_speaker_prefix():
@ -73,3 +80,48 @@ def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch):
_, kwargs = fake_client.models.generate_content.call_args _, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash" assert kwargs["model"] == "gemini-2.5-flash"
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"] assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]
def test_system_prompt_for_tier_none_returns_base():
assert system_prompt_for_tier(None) == SYSTEM_PROMPT
def test_system_prompt_for_tier_close_adds_instruction():
prompt = system_prompt_for_tier("close")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt
def test_system_prompt_for_tier_formal_adds_instruction():
prompt = system_prompt_for_tier("formal")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in prompt
def test_draft_reply_passes_relationship_tier_into_system_instruction(monkeypatch):
fake_response = MagicMock()
fake_response.text = "네 알겠습니다"
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
status, text = draft_reply(
["알겠습니다"],
["상대: 내일 회의 시간 괜찮으세요?"],
api_key="fake-key",
relationship_tier="formal",
)
assert status == "ok"
_, kwargs = fake_client.models.generate_content.call_args
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in kwargs["config"]["system_instruction"]

View File

@ -2,6 +2,7 @@
/escalate/check into pytest -- roadmap.md Phase 1 §2.5.""" /escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.main as main_module
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)
@ -105,3 +106,39 @@ def test_summarize_no_key(monkeypatch):
body = resp.json() body = resp.json()
assert body["status"] == "no_key" assert body["status"] == "no_key"
assert "토요일 모임 3시로 하자" in body["summary"] assert "토요일 모임 3시로 하자" in body["summary"]
def test_draft_passes_relationship_tier_through_to_draft_reply(monkeypatch):
captured = {}
def fake_draft_reply(style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None):
captured["relationship_tier"] = relationship_tier
return "ok", "네 알겠습니다"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 내일 회의 시간 괜찮으세요?"],
"style_examples": ["알겠습니다"],
"relationship_tier": "formal",
},
)
assert resp.status_code == 200
assert captured["relationship_tier"] == "formal"
def test_draft_relationship_tier_defaults_to_none(monkeypatch):
captured = {}
def fake_draft_reply(style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None):
captured["relationship_tier"] = relationship_tier
return "ok", "ㅇㅋ"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅋ"]},
)
assert resp.status_code == 200
assert captured["relationship_tier"] is None

View File

@ -18,12 +18,16 @@ type createContactRequest struct {
DisplayName string `json:"display_name" binding:"required"` DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"` ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"` RelationshipNote string `json:"relationship_note"`
// 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"`
} }
type updateContactRequest struct { type updateContactRequest struct {
DisplayName string `json:"display_name" binding:"required"` DisplayName string `json:"display_name" binding:"required"`
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"`
} }
func contactJSON(ct Contact) gin.H { func contactJSON(ct Contact) gin.H {
@ -32,6 +36,7 @@ func contactJSON(ct Contact) gin.H {
"display_name": ct.DisplayName, "display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID, "contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote, "relationship_note": ct.RelationshipNote,
"relationship_tier": ct.RelationshipTier,
} }
} }
@ -260,11 +265,16 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
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,
} }
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()})
@ -307,6 +317,10 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
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"})
@ -319,6 +333,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
contact.DisplayName = req.DisplayName contact.DisplayName = req.DisplayName
contact.ContactUserID = req.ContactUserID contact.ContactUserID = req.ContactUserID
contact.RelationshipNote = req.RelationshipNote contact.RelationshipNote = req.RelationshipNote
contact.RelationshipTier = req.RelationshipTier
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

@ -20,6 +20,10 @@ func newAIServiceClient() *AIServiceClient {
type draftRequest struct { type draftRequest struct {
ContextLines []string `json:"context_lines"` ContextLines []string `json:"context_lines"`
// RelationshipTier (roadmap.md §2.7-B): "close" | "formal". Empty is
// treated by ai-service as "no tier info" and falls back to its own
// default tone, same as before this field existed.
RelationshipTier string `json:"relationship_tier,omitempty"`
StyleExamples []string `json:"style_examples,omitempty"` StyleExamples []string `json:"style_examples,omitempty"`
History []string `json:"history,omitempty"` History []string `json:"history,omitempty"`
K int `json:"k,omitempty"` K int `json:"k,omitempty"`

View File

@ -35,6 +35,9 @@ type sendMessageRequest struct {
type updateTwinSettingsRequest struct { type updateTwinSettingsRequest struct {
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"` AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
// RelationshipTier is optional so existing autonomy-only PATCH calls
// keep working unchanged (roadmap.md §2.7-B). Nil = leave it as-is.
RelationshipTier *RelationshipTier `json:"relationship_tier"`
} }
type createWhitelistRuleRequest struct { type createWhitelistRuleRequest struct {
@ -202,7 +205,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal})
if !demo { if !demo {
now := time.Now() now := time.Now()
@ -413,9 +416,19 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
// 관계별 페르소나(roadmap.md §2.7-B): optional auth -- this endpoint
// has never required a session, so callers without one (existing
// tests, or a future anonymous path) still work exactly as before,
// just without tier resolution (falls back to "formal").
tier := RelationshipFormal
if actor, ok := currentUser(c, db, false); ok {
tier = resolveRelationshipTier(db, actor.ID, convID)
}
started := time.Now() started := time.Now()
result, err := ai.requestDraft(draftRequest{ result, err := ai.requestDraft(draftRequest{
ContextLines: req.ContextLines, ContextLines: req.ContextLines,
RelationshipTier: string(tier),
StyleExamples: req.StyleExamples, StyleExamples: req.StyleExamples,
History: req.History, History: req.History,
K: req.K, K: req.K,
@ -472,6 +485,10 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
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
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
var settings TwinSettings var settings TwinSettings
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil { if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
@ -479,12 +496,19 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
settings.AutonomyLevel = req.AutonomyLevel settings.AutonomyLevel = req.AutonomyLevel
if req.RelationshipTier != nil {
settings.RelationshipTier = *req.RelationshipTier
}
if err := db.Save(&settings).Error; err != nil { if err := db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel}) c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"autonomy_level": settings.AutonomyLevel,
"relationship_tier": settings.RelationshipTier,
})
}) })
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) { r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {

View File

@ -31,7 +31,13 @@ func mockAIService(t *testing.T) *httptest.Server {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) // Echo relationship_tier into the response text (roadmap.md
// §2.7-B) so tests can assert what core-backend resolved and
// forwarded, without needing a real ai-service.
json.NewEncoder(w).Encode(draftResponse{
Status: "ok",
Text: "mock draft [tier=" + req.RelationshipTier + "] for: " + strings.Join(req.ContextLines, " | "),
})
case "/escalate/check": case "/escalate/check":
// Mirrors escalation_filter.py's rule set closely enough to // Mirrors escalation_filter.py's rule set closely enough to
// exercise core-backend's gating logic; the rules themselves // exercise core-backend's gating logic; the rules themselves

View File

@ -17,6 +17,23 @@ const (
AutonomyL2 AutonomyLevel = "L2" AutonomyL2 AutonomyLevel = "L2"
) )
// 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
// more conservative "formal" tier for anyone who hasn't set this explicitly
// (same fail-safe-leaning default philosophy as AutonomyLevel defaulting
// to L0 when missing).
type RelationshipTier string
const (
RelationshipClose RelationshipTier = "close"
RelationshipFormal RelationshipTier = "formal"
)
func validRelationshipTier(t RelationshipTier) bool {
return t == RelationshipClose || t == RelationshipFormal
}
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
InviteCode string `gorm:"uniqueIndex;not null"` InviteCode string `gorm:"uniqueIndex;not null"`
@ -55,6 +72,10 @@ type Contact struct {
ContactUserID *uint ContactUserID *uint
DisplayName string `gorm:"not null"` DisplayName string `gorm:"not null"`
RelationshipNote string RelationshipNote string
// RelationshipTier overrides the owner's global TwinSettings.RelationshipTier
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
// "use the global default".
RelationshipTier *RelationshipTier
CreatedAt time.Time CreatedAt time.Time
} }
@ -98,6 +119,7 @@ type TwinSettings struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex;not null"` UserID uint `gorm:"uniqueIndex;not null"`
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"` AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
RelationshipTier RelationshipTier `gorm:"not null;default:formal"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }

34
core-backend/persona.go Normal file
View File

@ -0,0 +1,34 @@
package main
import "gorm.io/gorm"
// resolveRelationshipTier implements roadmap.md §2.7-B: draft tone should
// read differently for a close friend than for someone you'd stay formal
// with. Resolution order: contact-specific override (1:1 only) -> the
// sender's global TwinSettings default -> "formal" as the fail-safe
// fallback if nothing is set. Group conversations always use the global
// default since a group has more than one counterpart to pick a tier for.
func resolveRelationshipTier(db *gorm.DB, actorID, conversationID uint) RelationshipTier {
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.RelationshipTier != nil && validRelationshipTier(*contact.RelationshipTier) {
return *contact.RelationshipTier
}
break
}
}
var settings TwinSettings
if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validRelationshipTier(settings.RelationshipTier) {
return settings.RelationshipTier
}
return RelationshipFormal
}

View File

@ -0,0 +1,182 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func TestPatchTwinSettingsUpdatesRelationshipTier(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
tier := RelationshipClose
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL1,
RelationshipTier: &tier,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["relationship_tier"] != "close" {
t.Fatalf("expected relationship_tier close, got %v", out)
}
if out["autonomy_level"] != "L1" {
t.Fatalf("expected autonomy_level L1, got %v", out)
}
}
func TestPatchTwinSettingsRejectsInvalidRelationshipTier(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
bad := RelationshipTier("aloof")
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL0,
RelationshipTier: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestPatchTwinSettingsOmittingTierLeavesItUnchanged(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
userIDStr := strconv.FormatUint(uint64(userID), 10)
tier := RelationshipClose
patchJSONAuth(t, server.URL+"/users/"+userIDStr+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL1,
RelationshipTier: &tier,
})
// Existing autonomy-only callers must keep working exactly as before --
// omitting relationship_tier must not reset it back to the DB default.
resp := patchJSONAuth(t, server.URL+"/users/"+userIDStr+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL2,
})
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["relationship_tier"] != "close" {
t.Fatalf("expected relationship_tier to stay close, got %v", out)
}
}
func TestContactRelationshipTierOverridesGlobalDefaultInDraft(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// Global default starts as "formal" (signup default). Override just
// this contact to "close".
closeTier := RelationshipClose
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipTier: &closeTier,
})
if contactResp.StatusCode != http.StatusOK {
t.Fatalf("create contact: %d", contactResp.StatusCode)
}
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))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if draftResp.StatusCode != http.StatusOK {
t.Fatalf("draft: %d", draftResp.StatusCode)
}
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "tier=close") {
t.Fatalf("expected contact override tier=close in mock draft, got %v", draftOut.Text)
}
}
func TestDraftFallsBackToGlobalTierWithoutContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// No contact record at all -- resolveRelationshipTier must fall back to
// the signup default ("formal") rather than erroring.
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))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 내일 시간 되세요?"},
StyleExamples: []string{"네 됩니다"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "tier=formal") {
t.Fatalf("expected global default tier=formal in mock draft, got %v", draftOut.Text)
}
}
func TestDraftWithoutAuthDefaultsToFormalTier(t *testing.T) {
server, db := setupTestServer(t)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// No Authorization header at all -- this endpoint has never required
// one, so it must keep working (just without tier personalization).
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out draftResponse
json.NewDecoder(resp.Body).Decode(&out)
if !strings.Contains(out.Text, "tier=formal") {
t.Fatalf("expected default tier=formal for unauthenticated call, got %v", out.Text)
}
}
func TestGroupConversationDraftUsesGlobalTierNotContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
closeTier := RelationshipClose
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipTier: &closeTier,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"철수: 이번 주 토요일 모임 어때?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
// Group conversations ignore the per-contact override (roadmap.md
// §2.7-B: a group has more than one counterpart) and use the global
// default ("formal") instead.
if !strings.Contains(draftOut.Text, "tier=formal") {
t.Fatalf("expected group draft to use global tier=formal, got %v", draftOut.Text)
}
}

View File

@ -42,6 +42,8 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
GitHub+Gitea `main` 듀얼 푸시 · `msn.iykyka.com` web 재빌드 완료 GitHub+Gitea `main` 듀얼 푸시 · `msn.iykyka.com` web 재빌드 완료
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03, 아직 GitHub `main`에만 있고 - **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03, 아직 GitHub `main`에만 있고
프로덕션 미배포) — 그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 프로덕션 미배포) — 그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음
마커. C2(관계별 페르소나)도 **완료** (2026-08-03, 아직 프로덕션 미배포) — `relationship_tier`
전역 기본값+연락처별 오버라이드, 초안 생성 톤 프롬프트 분기. 다음: C3(스팸/도배 감지)
마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지). 마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지).
Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
@ -186,10 +188,10 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` | | **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 | | **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 | | **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 | | **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | **done** (2026-08-03) | `core-backend/models.go` `TwinSettings`(전역 기본값)·`Contact`(상대별 오버라이드) 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` | | **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 | | **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI |
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | todo | `ai-service/app/generation.py` | | **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) |
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) | | **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) |
| **N4-C3b** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 | | **N4-C3b** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 |
@ -250,9 +252,9 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
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 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)** ~~Track C2 관계별 페르소나~~ **done** (2026-08-03) — 다음: **Track C3 스팸/도배 감지**
N4-4 스모크 → Android UI QA (N4-5~10) 병행하며 **Master FCM 시크릿(N4-1/3)**N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -152,12 +152,20 @@
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려 버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨 있는 동안은 계속 읽음으로 따라가도 됨
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재 **2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종 — **완료** 2026-08-03)
`TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음) - [x] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `TwinSettings`
- [ ] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `core-backend/models.go` 추가, 신규 유저 기본값은 안전 우선으로 `formal`. `Contact`에도 상대별 오버라이드 필드 추가
- [ ] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart` (현재는 말투 샘플 4개뿐) - [x] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역
- [ ] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart`, 자율성 레벨의 상대별 예외와 동일 패턴 기본값 선택(`SegmentedButton`)
- [ ] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py` 시스템 프롬프트에 주입 - [x] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart` 연락처 추가/수정 다이얼로그에
`_RelationshipTierPicker`(기본값 사용/가까운 사이/공식적인 사이 3-way 칩), 자율성 화면에도
전역 기본값 변경 UI 추가
- [x] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py`
`RELATIONSHIP_TIER_INSTRUCTIONS` + `system_prompt_for_tier()`. `core-backend`
`POST /conversations/:id/draft` 호출마다 `resolveRelationshipTier()`로 해석(연락처 오버라이드
→ 전역 기본값 → `formal`) 해서 `ai-service`로 전달. 그룹 대화는 상대가 여럿이라 항상 전역
기본값만 사용. 이 엔드포인트가 원래 인증을 요구하지 않던 걸 깨지 않도록 `currentUser(..., false)`
선택적 인증 처리 — 토큰 없는 기존 호출도 그대로 동작(티어 해석만 스킵)
**2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로 **2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로
명시 — 현재 코드 전체에 관련 로직 0건) 명시 — 현재 코드 전체에 관련 로직 0건)

View File

@ -4,6 +4,19 @@ enum SenderMode { human, twin }
// ignore: constant_identifier_names // ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 } enum AutonomyLevel { L0, L1, L2 }
/// (roadmap.md §2.7-B, PRD.md §2.1-/§3.1) minimum 2 tiers.
enum RelationshipTier {
close,
formal;
static RelationshipTier fromJson(String? raw) => RelationshipTier.values.firstWhere(
(e) => e.name == raw,
orElse: () => RelationshipTier.formal,
);
String get label => this == RelationshipTier.close ? '가까운 사이' : '공식적인 사이';
}
class User { class User {
User({required this.id, required this.displayName, required this.inviteCode}); User({required this.id, required this.displayName, required this.inviteCode});
@ -19,9 +32,10 @@ class User {
} }
class TwinSettings { class TwinSettings {
TwinSettings({required this.autonomyLevel}); TwinSettings({required this.autonomyLevel, required this.relationshipTier});
final AutonomyLevel autonomyLevel; final AutonomyLevel autonomyLevel;
final RelationshipTier relationshipTier;
factory TwinSettings.fromJson(Map<String, dynamic> json) { factory TwinSettings.fromJson(Map<String, dynamic> json) {
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase(); final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
@ -30,6 +44,7 @@ class TwinSettings {
(e) => e.name == raw, (e) => e.name == raw,
orElse: () => AutonomyLevel.L0, orElse: () => AutonomyLevel.L0,
), ),
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
); );
} }
} }
@ -78,18 +93,25 @@ class Contact {
required this.displayName, required this.displayName,
this.contactUserId, this.contactUserId,
this.relationshipNote = '', this.relationshipNote = '',
this.relationshipTier,
}); });
final int id; final int id;
final String displayName; final String displayName;
final int? contactUserId; final int? contactUserId;
final String relationshipNote; final String relationshipNote;
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). Null means "use the global default".
final RelationshipTier? relationshipTier;
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,
displayName: json['display_name'] as String? ?? '', displayName: json['display_name'] as String? ?? '',
contactUserId: json['contact_user_id'] as int?, contactUserId: json['contact_user_id'] as int?,
relationshipNote: json['relationship_note'] as String? ?? '', relationshipNote: json['relationship_note'] as String? ?? '',
relationshipTier: json['relationship_tier'] == null
? null
: RelationshipTier.fromJson(json['relationship_tier'] as String?),
); );
} }

View File

@ -129,6 +129,21 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Text('기본 관계 설정', style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(
'연락처별로 다르게 설정하지 않은 상대에게는 이 기본값이 적용됩니다.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
SegmentedButton<RelationshipTier>(
segments: RelationshipTier.values
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
.toList(),
selected: {session.relationshipTier},
onSelectionChanged: (s) => session.setRelationshipTier(s.first),
),
const SizedBox(height: 28),
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium), Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(

View File

@ -48,9 +48,11 @@ class _ContactsScreenState extends State<ContactsScreen> {
final noteCtrl = TextEditingController(); final noteCtrl = TextEditingController();
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final myId = session.user?.id; final myId = session.user?.id;
RelationshipTier? tierOverride;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('연락처 추가'), title: const Text('연락처 추가'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@ -82,6 +84,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
controller: noteCtrl, controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)'), decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
), ),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
], ],
), ),
), ),
@ -90,6 +99,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
], ],
), ),
),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
@ -109,6 +119,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name, displayName: name,
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
); );
setState(() { setState(() {
_contacts = [..._contacts, created]; _contacts = [..._contacts, created];
@ -149,9 +160,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
final noteCtrl = TextEditingController(text: contact.relationshipNote); final noteCtrl = TextEditingController(text: contact.relationshipNote);
final session = context.read<SessionState>(); final session = context.read<SessionState>();
// PATCH는 (=
// ) null로 .
RelationshipTier? tierOverride = contact.relationshipTier;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'), title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@ -184,6 +199,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
controller: noteCtrl, controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)'), decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
), ),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
], ],
), ),
), ),
@ -192,6 +214,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
], ],
), ),
),
); );
if (ok != true || !mounted || session.user == null) return; if (ok != true || !mounted || session.user == null) return;
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
@ -212,6 +235,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name, displayName: name,
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
); );
setState(() { setState(() {
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
@ -333,6 +357,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)' ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
: [ : [
'사용자 #${c.contactUserId}', '사용자 #${c.contactUserId}',
if (c.relationshipTier != null) c.relationshipTier!.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(
@ -381,3 +406,32 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
} }
} }
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). `value: null` means "use the global default set in onboarding".
class _RelationshipTierPicker extends StatelessWidget {
const _RelationshipTierPicker({required this.value, required this.onChanged});
final RelationshipTier? value;
final ValueChanged<RelationshipTier?> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('기본값 사용'),
selected: value == null,
onSelected: (_) => onChanged(null),
),
for (final tier in RelationshipTier.values)
ChoiceChip(
label: Text(tier.label),
selected: value == tier,
onSelected: (_) => onChanged(tier),
),
],
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/models.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/primary_gradient_button.dart'; import '../widgets/primary_gradient_button.dart';
@ -16,16 +17,19 @@ class OnboardingToneScreen extends StatefulWidget {
class _OnboardingToneScreenState extends State<OnboardingToneScreen> { class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
final _samples = List.generate(4, (_) => TextEditingController()); final _samples = List.generate(4, (_) => TextEditingController());
var _seeded = false; var _seeded = false;
RelationshipTier _tier = RelationshipTier.formal;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (_seeded) return; if (_seeded) return;
_seeded = true; _seeded = true;
final existing = context.read<SessionState>().styleExamples; final session = context.read<SessionState>();
final existing = session.styleExamples;
for (var i = 0; i < existing.length && i < _samples.length; i++) { for (var i = 0; i < existing.length && i < _samples.length; i++) {
_samples[i].text = existing[i]; _samples[i].text = existing[i];
} }
_tier = session.relationshipTier;
} }
@override @override
@ -42,6 +46,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
_samples.map((c) => c.text).toList(), _samples.map((c) => c.text).toList(),
markDone: markDone, markDone: markDone,
); );
await session.setRelationshipTier(_tier);
} }
@override @override
@ -84,7 +89,22 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
const SizedBox(height: 8), const SizedBox(height: 16),
Text('기본 관계 설정', style: theme.textTheme.titleSmall),
const SizedBox(height: 6),
Text(
'와카뷰가 초안을 쓸 때 기본으로 쓸 말투 격식이에요. 상대별로 나중에 연락처에서 따로 바꿀 수 있어요.',
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
const SizedBox(height: 10),
SegmentedButton<RelationshipTier>(
segments: RelationshipTier.values
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
.toList(),
selected: {_tier},
onSelectionChanged: (s) => setState(() => _tier = s.first),
),
const SizedBox(height: 24),
PrimaryGradientButton( PrimaryGradientButton(
label: '이 말투로 시작', label: '이 말투로 시작',
onPressed: () => _save(markDone: true), onPressed: () => _save(markDone: true),

View File

@ -118,11 +118,13 @@ class ApiClient {
required String displayName, required String displayName,
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier,
}) 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,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }
@ -133,11 +135,13 @@ class ApiClient {
required String displayName, required String displayName,
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier,
}) 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,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }
@ -201,9 +205,14 @@ class ApiClient {
await _json('POST', '/messages/$messageId/retract'); await _json('POST', '/messages/$messageId/retract');
} }
Future<TwinSettings> patchTwinSettings(int userId, AutonomyLevel level) async { Future<TwinSettings> patchTwinSettings({
required int userId,
required AutonomyLevel autonomyLevel,
RelationshipTier? relationshipTier,
}) async {
final json = await _json('PATCH', '/users/$userId/twin-settings', body: { final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
'autonomy_level': level.name, 'autonomy_level': autonomyLevel.name,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
}); });
return TwinSettings.fromJson(json); return TwinSettings.fromJson(json);
} }

View File

@ -18,6 +18,7 @@ class SessionState extends ChangeNotifier {
User? user; User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0; AutonomyLevel autonomyLevel = AutonomyLevel.L0;
RelationshipTier relationshipTier = RelationshipTier.formal;
String? error; String? error;
bool loading = false; bool loading = false;
bool toneOnboardingDone = false; bool toneOnboardingDone = false;
@ -139,8 +140,9 @@ class SessionState extends ChangeNotifier {
Future<void> setAutonomy(AutonomyLevel level) async { Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return; if (user == null) return;
try { try {
final settings = await _api.patchTwinSettings(user!.id, level); final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level);
autonomyLevel = settings.autonomyLevel; autonomyLevel = settings.autonomyLevel;
relationshipTier = settings.relationshipTier;
notifyListeners(); notifyListeners();
} on ApiException catch (e) { } on ApiException catch (e) {
error = '자율성 변경 실패 (${e.statusCode})'; error = '자율성 변경 실패 (${e.statusCode})';
@ -148,6 +150,24 @@ class SessionState extends ChangeNotifier {
} }
} }
/// (roadmap.md §2.7-B).
Future<void> setRelationshipTier(RelationshipTier tier) async {
if (user == null) return;
try {
final settings = await _api.patchTwinSettings(
userId: user!.id,
autonomyLevel: autonomyLevel,
relationshipTier: tier,
);
autonomyLevel = settings.autonomyLevel;
relationshipTier = settings.relationshipTier;
notifyListeners();
} on ApiException catch (e) {
error = '관계 설정 변경 실패 (${e.statusCode})';
notifyListeners();
}
}
/// Registers a real FCM token when Firebase is configured; otherwise a stable /// Registers a real FCM token when Firebase is configured; otherwise a stable
/// `install:` placeholder (server skips placeholders for delivery). /// `install:` placeholder (server skips placeholders for delivery).
Future<void> _registerDeviceTokenBestEffort() async { Future<void> _registerDeviceTokenBestEffort() async {