관계별 페르소나(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:
parent
0d848bcb9a
commit
5733ec4399
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -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건)
|
||||||
|
|
|
||||||
|
|
@ -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?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue