From 5733ec4399600ab95b793688a9f8c1e9d6654354 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:59:34 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EA=B3=84=EB=B3=84=20=ED=8E=98?= =?UTF-8?q?=EB=A5=B4=EC=86=8C=EB=82=98(2.7-B)=20=EA=B5=AC=ED=98=84:=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=ED=8B=B0=EC=96=B4=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?+=20=EC=98=A8=EB=B3=B4=EB=94=A9/=EC=97=B0=EB=9D=BD=EC=B2=98=20U?= =?UTF-8?q?I=20+=20=ED=86=A4=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m --- ai-service/app/generation.py | 24 ++- ai-service/app/main.py | 5 +- ai-service/tests/test_generation.py | 54 ++++- ai-service/tests/test_main.py | 37 ++++ core-backend/a1_a2_routes.go | 21 +- core-backend/aiservice.go | 12 +- core-backend/main.go | 36 +++- core-backend/main_test.go | 8 +- core-backend/models.go | 32 ++- core-backend/persona.go | 34 +++ core-backend/persona_test.go | 182 ++++++++++++++++ docs/deploy-checklist.md | 16 +- docs/roadmap.md | 20 +- mobile/lib/models/models.dart | 24 ++- .../lib/screens/autonomy_settings_screen.dart | 15 ++ mobile/lib/screens/contacts_screen.dart | 198 +++++++++++------- .../lib/screens/onboarding_tone_screen.dart | 24 ++- mobile/lib/services/api_client.dart | 13 +- mobile/lib/state/session_state.dart | 22 +- 19 files changed, 663 insertions(+), 114 deletions(-) create mode 100644 core-backend/persona.go create mode 100644 core-backend/persona_test.go diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py index 36c2259..dbb3122 100644 --- a/ai-service/app/generation.py +++ b/ai-service/app/generation.py @@ -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): examples = "\n".join(f"- {s}" for s in style_examples) 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 -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". "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "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) 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( model=model, 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() diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 46172f2..8b38775 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -30,6 +30,7 @@ class DraftRequest(BaseModel): history: Optional[List[str]] = None k: int = 6 model: str = "gemini-2.5-flash" + relationship_tier: Optional[str] = None @model_validator(mode="after") 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 ) - 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) diff --git a/ai-service/tests/test_generation.py b/ai-service/tests/test_generation.py index 1bf4725..b1c2fbb 100644 --- a/ai-service/tests/test_generation.py +++ b/ai-service/tests/test_generation.py @@ -6,7 +6,14 @@ import sys import types 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(): @@ -73,3 +80,48 @@ def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch): _, kwargs = fake_client.models.generate_content.call_args assert kwargs["model"] == "gemini-2.5-flash" 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"] diff --git a/ai-service/tests/test_main.py b/ai-service/tests/test_main.py index 9b5a781..fe4c930 100644 --- a/ai-service/tests/test_main.py +++ b/ai-service/tests/test_main.py @@ -2,6 +2,7 @@ /escalate/check into pytest -- roadmap.md Phase 1 §2.5.""" from fastapi.testclient import TestClient +import app.main as main_module from app.main import app client = TestClient(app) @@ -105,3 +106,39 @@ def test_summarize_no_key(monkeypatch): body = resp.json() assert body["status"] == "no_key" 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 diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index 155d8b6..8600b92 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -18,12 +18,16 @@ type createContactRequest struct { DisplayName string `json:"display_name" binding:"required"` ContactUserID *uint `json:"contact_user_id"` 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 { - DisplayName string `json:"display_name" binding:"required"` - ContactUserID *uint `json:"contact_user_id"` - RelationshipNote string `json:"relationship_note"` + DisplayName string `json:"display_name" binding:"required"` + ContactUserID *uint `json:"contact_user_id"` + RelationshipNote string `json:"relationship_note"` + RelationshipTier *RelationshipTier `json:"relationship_tier"` } func contactJSON(ct Contact) gin.H { @@ -32,6 +36,7 @@ func contactJSON(ct Contact) gin.H { "display_name": ct.DisplayName, "contact_user_id": ct.ContactUserID, "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()}) 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{ OwnerUserID: userID, ContactUserID: req.ContactUserID, DisplayName: req.DisplayName, RelationshipNote: req.RelationshipNote, + RelationshipTier: req.RelationshipTier, } if err := db.Create(&contact).Error; err != nil { 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()}) 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 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"}) @@ -319,6 +333,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { contact.DisplayName = req.DisplayName contact.ContactUserID = req.ContactUserID contact.RelationshipNote = req.RelationshipNote + contact.RelationshipTier = req.RelationshipTier if err := db.Save(&contact).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go index 746a76c..3fdc45d 100644 --- a/core-backend/aiservice.go +++ b/core-backend/aiservice.go @@ -19,10 +19,14 @@ func newAIServiceClient() *AIServiceClient { } type draftRequest struct { - ContextLines []string `json:"context_lines"` - StyleExamples []string `json:"style_examples,omitempty"` - History []string `json:"history,omitempty"` - K int `json:"k,omitempty"` + 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"` + History []string `json:"history,omitempty"` + K int `json:"k,omitempty"` } type draftResponse struct { diff --git a/core-backend/main.go b/core-backend/main.go index 0b10dbe..ae3c49b 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -35,6 +35,9 @@ type sendMessageRequest struct { type updateTwinSettingsRequest struct { 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 { @@ -202,7 +205,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) + db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal}) if !demo { now := time.Now() @@ -413,12 +416,22 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi 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() result, err := ai.requestDraft(draftRequest{ - ContextLines: req.ContextLines, - StyleExamples: req.StyleExamples, - History: req.History, - K: req.K, + ContextLines: req.ContextLines, + RelationshipTier: string(tier), + StyleExamples: req.StyleExamples, + History: req.History, + K: req.K, }) runtimeMetrics.recordDraft(time.Since(started), err) if err != nil { @@ -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"}) 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 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 } settings.AutonomyLevel = req.AutonomyLevel + if req.RelationshipTier != nil { + settings.RelationshipTier = *req.RelationshipTier + } if err := db.Save(&settings).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) 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) { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 5251d88..67ac31a 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -31,7 +31,13 @@ func mockAIService(t *testing.T) *httptest.Server { return } 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": // Mirrors escalation_filter.py's rule set closely enough to // exercise core-backend's gating logic; the rules themselves diff --git a/core-backend/models.go b/core-backend/models.go index 7e456ed..d63b462 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -17,6 +17,23 @@ const ( 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 { ID uint `gorm:"primaryKey"` InviteCode string `gorm:"uniqueIndex;not null"` @@ -55,6 +72,10 @@ type Contact struct { ContactUserID *uint DisplayName string `gorm:"not null"` 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 } @@ -95,11 +116,12 @@ type Message struct { } type TwinSettings struct { - ID uint `gorm:"primaryKey"` - UserID uint `gorm:"uniqueIndex;not null"` - AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"` - CreatedAt time.Time - UpdatedAt time.Time + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex;not null"` + AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"` + RelationshipTier RelationshipTier `gorm:"not null;default:formal"` + CreatedAt time.Time + UpdatedAt time.Time } // WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the diff --git a/core-backend/persona.go b/core-backend/persona.go new file mode 100644 index 0000000..dd09062 --- /dev/null +++ b/core-backend/persona.go @@ -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 +} diff --git a/core-backend/persona_test.go b/core-backend/persona_test.go new file mode 100644 index 0000000..70d8aec --- /dev/null +++ b/core-backend/persona_test.go @@ -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) + } +} diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 01a2146..9518631 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -42,6 +42,8 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. GitHub+Gitea `main` 듀얼 푸시 · `msn.iykyka.com` web 재빌드 완료 - **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포) — 그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 + 마커. C2(관계별 페르소나)도 **완료** (2026-08-03, 아직 프로덕션 미배포) — `relationship_tier` + 전역 기본값+연락처별 오버라이드, 초안 생성 톤 프롬프트 분기. 다음: C3(스팸/도배 감지) 마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지). Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 - 실 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-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 | | **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 | -| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 | -| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` | -| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 | -| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | todo | `ai-service/app/generation.py` | +| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | **done** (2026-08-03) | `core-backend/models.go` `TwinSettings`(전역 기본값)·`Contact`(상대별 오버라이드) 확장 | +| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 | +| **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI | +| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) | | **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) | | **N4-C3b** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 | @@ -250,9 +252,9 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) -5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음: - **Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)** → - N4-4 스모크 → Android UI QA (N4-5~10) +5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), + ~~Track C2 관계별 페르소나~~ **done** (2026-08-03) — 다음: **Track C3 스팸/도배 감지** + 병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 994d103..7f3f4dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -152,12 +152,20 @@ 버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려 있는 동안은 계속 읽음으로 따라가도 됨 -**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재 -`TwinSettings`엔 `AutonomyLevel`만 있고 페르소나 필드 없음) -- [ ] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `core-backend/models.go` -- [ ] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart` (현재는 말투 샘플 4개뿐) -- [ ] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart`, 자율성 레벨의 상대별 예외와 동일 패턴 -- [ ] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py` 시스템 프롬프트에 주입 +**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종 — **완료** 2026-08-03) +- [x] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `TwinSettings`에 + 추가, 신규 유저 기본값은 안전 우선으로 `formal`. `Contact`에도 상대별 오버라이드 필드 추가 +- [x] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 + 기본값 선택(`SegmentedButton`) +- [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 최소 버전 필요"로 명시 — 현재 코드 전체에 관련 로직 0건) diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index 60454e2..f69471f 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -4,6 +4,19 @@ enum SenderMode { human, twin } // ignore: constant_identifier_names 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 { User({required this.id, required this.displayName, required this.inviteCode}); @@ -19,9 +32,10 @@ class User { } class TwinSettings { - TwinSettings({required this.autonomyLevel}); + TwinSettings({required this.autonomyLevel, required this.relationshipTier}); final AutonomyLevel autonomyLevel; + final RelationshipTier relationshipTier; factory TwinSettings.fromJson(Map json) { final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase(); @@ -30,6 +44,7 @@ class TwinSettings { (e) => e.name == raw, orElse: () => AutonomyLevel.L0, ), + relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?), ); } } @@ -78,18 +93,25 @@ class Contact { required this.displayName, this.contactUserId, this.relationshipNote = '', + this.relationshipTier, }); final int id; final String displayName; final int? contactUserId; 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 json) => Contact( id: json['id'] as int, displayName: json['display_name'] as String? ?? '', contactUserId: json['contact_user_id'] as int?, relationshipNote: json['relationship_note'] as String? ?? '', + relationshipTier: json['relationship_tier'] == null + ? null + : RelationshipTier.fromJson(json['relationship_tier'] as String?), ); } diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart index fa4412d..d9c8e7d 100644 --- a/mobile/lib/screens/autonomy_settings_screen.dart +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -129,6 +129,21 @@ class _AutonomySettingsScreenState extends State { ), ), 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( + 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), const SizedBox(height: 12), Row( diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index 30d24ac..b49b459 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -48,47 +48,57 @@ class _ContactsScreenState extends State { final noteCtrl = TextEditingController(); final session = context.read(); final myId = session.user?.id; + RelationshipTier? tierOverride; final ok = await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('연락처 추가'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (myId != null) ...[ - MyUserIdChip(userId: myId), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text('연락처 추가'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (myId != null) ...[ + MyUserIdChip(userId: myId), + const SizedBox(height: 12), + ], + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: '표시 이름', + helperText: '목록에 보일 이름 (예: 친구 닉네임)', + ), + ), const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (숫자, 필수)', + helperText: '대화하려면 상대의 숫자 ID가 필요합니다. 이름만으로는 안 됩니다.', + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + 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), + ), ], - TextField( - controller: nameCtrl, - decoration: const InputDecoration( - labelText: '표시 이름', - helperText: '목록에 보일 이름 (예: 친구 닉네임)', - ), - ), - const SizedBox(height: 12), - TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '상대 사용자 ID (숫자, 필수)', - helperText: '대화하려면 상대의 숫자 ID가 필요합니다. 이름만으로는 안 됩니다.', - ), - ), - const SizedBox(height: 12), - TextField( - controller: noteCtrl, - decoration: const InputDecoration(labelText: '관계 메모 (선택)'), - ), - ], + ), ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')), - ], ), ); if (ok != true || !mounted) return; @@ -109,6 +119,7 @@ class _ContactsScreenState extends State { displayName: name, contactUserId: peer, relationshipNote: noteCtrl.text.trim(), + relationshipTier: tierOverride, ); setState(() { _contacts = [..._contacts, created]; @@ -149,48 +160,60 @@ class _ContactsScreenState extends State { ); final noteCtrl = TextEditingController(text: contact.relationshipNote); final session = context.read(); + // PATCH는 전체 교체라 매번 이 값을 그대로 다시 보낸다 — 안 보내면(=이전 + // 코드처럼 필드 자체를 안 넣으면) 서버가 기존 오버라이드를 null로 되돌림. + RelationshipTier? tierOverride = contact.relationshipTier; final ok = await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - contact.contactUserId == null - ? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.' - : '표시 이름·상대 ID·메모를 고칠 수 있습니다.', - style: Theme.of(ctx).textTheme.bodySmall, - ), - const SizedBox(height: 12), - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: '표시 이름'), - ), - const SizedBox(height: 12), - TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - autofocus: contact.contactUserId == null, - decoration: const InputDecoration( - labelText: '상대 사용자 ID (숫자, 필수)', - helperText: '상대 대화 목록에 보이는 숫자 ID', + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + contact.contactUserId == null + ? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.' + : '표시 이름·상대 ID·메모를 고칠 수 있습니다.', + style: Theme.of(ctx).textTheme.bodySmall, ), - ), - const SizedBox(height: 12), - TextField( - controller: noteCtrl, - decoration: const InputDecoration(labelText: '관계 메모 (선택)'), - ), - ], + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: '표시 이름'), + ), + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + autofocus: contact.contactUserId == null, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (숫자, 필수)', + helperText: '상대 대화 목록에 보이는 숫자 ID', + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + 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), + ), + ], + ), ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')), - ], ), ); if (ok != true || !mounted || session.user == null) return; @@ -212,6 +235,7 @@ class _ContactsScreenState extends State { displayName: name, contactUserId: peer, relationshipNote: noteCtrl.text.trim(), + relationshipTier: tierOverride, ); setState(() { _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); @@ -333,6 +357,7 @@ class _ContactsScreenState extends State { ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)' : [ '사용자 #${c.contactUserId}', + if (c.relationshipTier != null) c.relationshipTier!.label, if (c.relationshipNote.isNotEmpty) c.relationshipNote, ].join(' · '), style: theme.textTheme.bodySmall?.copyWith( @@ -381,3 +406,32 @@ class _ContactsScreenState extends State { ); } } + +/// 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 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), + ), + ], + ); + } +} diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart index b0d7e91..bd4f3df 100644 --- a/mobile/lib/screens/onboarding_tone_screen.dart +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/models.dart'; import '../state/session_state.dart'; import '../widgets/primary_gradient_button.dart'; @@ -16,16 +17,19 @@ class OnboardingToneScreen extends StatefulWidget { class _OnboardingToneScreenState extends State { final _samples = List.generate(4, (_) => TextEditingController()); var _seeded = false; + RelationshipTier _tier = RelationshipTier.formal; @override void didChangeDependencies() { super.didChangeDependencies(); if (_seeded) return; _seeded = true; - final existing = context.read().styleExamples; + final session = context.read(); + final existing = session.styleExamples; for (var i = 0; i < existing.length && i < _samples.length; i++) { _samples[i].text = existing[i]; } + _tier = session.relationshipTier; } @override @@ -42,6 +46,7 @@ class _OnboardingToneScreenState extends State { _samples.map((c) => c.text).toList(), markDone: markDone, ); + await session.setRelationshipTier(_tier); } @override @@ -84,7 +89,22 @@ class _OnboardingToneScreenState extends State { ), 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( + 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( label: '이 말투로 시작', onPressed: () => _save(markDone: true), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index f346ea6..3d3ca1f 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -118,11 +118,13 @@ class ApiClient { required String displayName, int? contactUserId, String relationshipNote = '', + RelationshipTier? relationshipTier, }) 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, }); return Contact.fromJson(json); } @@ -133,11 +135,13 @@ class ApiClient { required String displayName, int? contactUserId, String relationshipNote = '', + RelationshipTier? relationshipTier, }) 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, }); return Contact.fromJson(json); } @@ -201,9 +205,14 @@ class ApiClient { await _json('POST', '/messages/$messageId/retract'); } - Future patchTwinSettings(int userId, AutonomyLevel level) async { + Future patchTwinSettings({ + required int userId, + required AutonomyLevel autonomyLevel, + RelationshipTier? relationshipTier, + }) async { 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); } diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 1f6da82..3000a7b 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -18,6 +18,7 @@ class SessionState extends ChangeNotifier { User? user; AutonomyLevel autonomyLevel = AutonomyLevel.L0; + RelationshipTier relationshipTier = RelationshipTier.formal; String? error; bool loading = false; bool toneOnboardingDone = false; @@ -139,8 +140,9 @@ class SessionState extends ChangeNotifier { Future setAutonomy(AutonomyLevel level) async { if (user == null) return; try { - final settings = await _api.patchTwinSettings(user!.id, level); + final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level); autonomyLevel = settings.autonomyLevel; + relationshipTier = settings.relationshipTier; notifyListeners(); } on ApiException catch (e) { error = '자율성 변경 실패 (${e.statusCode})'; @@ -148,6 +150,24 @@ class SessionState extends ChangeNotifier { } } + /// 관계별 페르소나 전역 기본값 변경 (roadmap.md §2.7-B). + Future 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 /// `install:` placeholder (server skips placeholders for delivery). Future _registerDeviceTokenBestEffort() async {