diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py index dbb3122..b704dd9 100644 --- a/ai-service/app/generation.py +++ b/ai-service/app/generation.py @@ -57,8 +57,16 @@ RELATIONSHIP_TIER_INSTRUCTIONS = { } -def system_prompt_for_tier(relationship_tier=None): +def system_prompt_for_tier(relationship_tier=None, relationship_note=None): + """relationship_note (roadmap.md §2.7-E): a free-text per-contact note + like "호칭: 자기야, 절대 언급 금지: 전 여친" (Contact.RelationshipNote). Purely + a tone/content hint about *this specific person* -- distinct from the + close/formal tier instruction above, and never a bypass of the + escalation/identity gates in draft_reply(). Empty/None leaves the prompt + unchanged.""" extra = RELATIONSHIP_TIER_INSTRUCTIONS.get(relationship_tier, "") + if relationship_note: + extra += f"\n\n[관계 메모] {relationship_note} -- 위 내용을 참고해 호칭/금기어 등을 지켜라." return SYSTEM_PROMPT + extra @@ -76,7 +84,14 @@ 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, relationship_tier=None): +def draft_reply( + style_examples, + context_lines, + model="gemini-2.5-flash", + api_key=None, + relationship_tier=None, + relationship_note=None, +): """Returns (status, text). status is one of "escalate" | "no_key" | "ok". "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). @@ -85,6 +100,10 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key relationship_tier: "close" | "formal" | None (roadmap.md §2.7-B). Only nudges tone -- escalation/identity gating above is unaffected by it. + + relationship_note: free-text per-contact note (roadmap.md §2.7-E), e.g. + "호칭: 자기야, 절대 언급 금지: 전 여친". Also only a tone/content hint -- + escalation/identity gating above is unaffected by it, same as tier. """ incoming = last_incoming_text(context_lines) gate = check_escalation(incoming) @@ -108,7 +127,8 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key model=model, contents=build_user_prompt(style_examples, context_lines), config=types.GenerateContentConfig( - system_instruction=system_prompt_for_tier(relationship_tier), max_output_tokens=300 + system_instruction=system_prompt_for_tier(relationship_tier, relationship_note), + max_output_tokens=300, ), ) return "ok", resp.text.strip() diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 8b38775..b013ef6 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -31,6 +31,7 @@ class DraftRequest(BaseModel): k: int = 6 model: str = "gemini-2.5-flash" relationship_tier: Optional[str] = None + relationship_note: Optional[str] = None @model_validator(mode="after") def check_exactly_one_style_source(self): @@ -73,7 +74,11 @@ def draft(req: DraftRequest): ) status, text = draft_reply( - style_examples, req.context_lines, model=req.model, relationship_tier=req.relationship_tier + style_examples, + req.context_lines, + model=req.model, + relationship_tier=req.relationship_tier, + relationship_note=req.relationship_note, ) return DraftResponse(status=status, text=text) diff --git a/ai-service/tests/test_generation.py b/ai-service/tests/test_generation.py index b1c2fbb..f0d88a7 100644 --- a/ai-service/tests/test_generation.py +++ b/ai-service/tests/test_generation.py @@ -98,6 +98,24 @@ def test_system_prompt_for_tier_formal_adds_instruction(): assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in prompt +def test_system_prompt_for_tier_includes_relationship_note_when_present(): + note = "호칭: 자기야, 절대 언급 금지: 전 여친" + prompt = system_prompt_for_tier("close", note) + assert prompt.startswith(SYSTEM_PROMPT) + assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt + assert "[관계 메모]" in prompt + assert note in prompt + + +def test_system_prompt_without_note_unaffected(): + # No note passed at all (default None) must produce the exact same + # prompt as before this field existed. + assert system_prompt_for_tier("close", None) == system_prompt_for_tier("close") + assert "[관계 메모]" not in system_prompt_for_tier("close") + # Explicit empty string must behave the same as None (falsy check). + assert system_prompt_for_tier("formal", "") == system_prompt_for_tier("formal") + + def test_draft_reply_passes_relationship_tier_into_system_instruction(monkeypatch): fake_response = MagicMock() fake_response.text = "네 알겠습니다" @@ -125,3 +143,34 @@ def test_draft_reply_passes_relationship_tier_into_system_instruction(monkeypatc assert status == "ok" _, kwargs = fake_client.models.generate_content.call_args assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in kwargs["config"]["system_instruction"] + + +def test_draft_reply_passes_relationship_note_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) + + note = "호칭: 자기야, 절대 언급 금지: 전 여친" + status, text = draft_reply( + ["알겠습니다"], + ["상대: 내일 회의 시간 괜찮으세요?"], + api_key="fake-key", + relationship_tier="formal", + relationship_note=note, + ) + + assert status == "ok" + _, kwargs = fake_client.models.generate_content.call_args + assert note in kwargs["config"]["system_instruction"] diff --git a/ai-service/tests/test_main.py b/ai-service/tests/test_main.py index fe4c930..18ad216 100644 --- a/ai-service/tests/test_main.py +++ b/ai-service/tests/test_main.py @@ -111,7 +111,9 @@ def test_summarize_no_key(monkeypatch): 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): + def fake_draft_reply( + style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None + ): captured["relationship_tier"] = relationship_tier return "ok", "네 알겠습니다" @@ -131,7 +133,9 @@ def test_draft_passes_relationship_tier_through_to_draft_reply(monkeypatch): 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): + def fake_draft_reply( + style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None + ): captured["relationship_tier"] = relationship_tier return "ok", "ㅇㅋ" @@ -142,3 +146,43 @@ def test_draft_relationship_tier_defaults_to_none(monkeypatch): ) assert resp.status_code == 200 assert captured["relationship_tier"] is None + + +def test_draft_passes_relationship_note_through_to_draft_reply(monkeypatch): + captured = {} + + def fake_draft_reply( + style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None + ): + captured["relationship_note"] = relationship_note + return "ok", "네 알겠습니다" + + monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply) + resp = client.post( + "/draft", + json={ + "context_lines": ["상대: 자기야 오늘 뭐해?"], + "style_examples": ["응 집이야"], + "relationship_note": "호칭: 자기야, 절대 언급 금지: 전 여친", + }, + ) + assert resp.status_code == 200 + assert captured["relationship_note"] == "호칭: 자기야, 절대 언급 금지: 전 여친" + + +def test_draft_relationship_note_defaults_to_none(monkeypatch): + captured = {} + + def fake_draft_reply( + style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None + ): + captured["relationship_note"] = relationship_note + 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_note"] is None diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go index 3fdc45d..ffe8aa5 100644 --- a/core-backend/aiservice.go +++ b/core-backend/aiservice.go @@ -23,7 +23,12 @@ type draftRequest struct { // 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"` + RelationshipTier string `json:"relationship_tier,omitempty"` + // RelationshipNote (roadmap.md §2.7-E): the contact's free-text note + // (e.g. "호칭: 자기야, 절대 언급 금지: 전 여친"). Empty means "no note" and + // ai-service's prompt gets no extra injection, same as before this field + // existed. + RelationshipNote string `json:"relationship_note,omitempty"` StyleExamples []string `json:"style_examples,omitempty"` History []string `json:"history,omitempty"` K int `json:"k,omitempty"` diff --git a/core-backend/autonomy_resolve.go b/core-backend/autonomy_resolve.go index 2645f64..d09f04d 100644 --- a/core-backend/autonomy_resolve.go +++ b/core-backend/autonomy_resolve.go @@ -6,31 +6,21 @@ import "gorm.io/gorm" // (auto-send) with a close friend but L0 (drafts only) with someone else -- // PRD.md §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정" and §4 // "사용자가 여러 상대에게 다른 자율성 레벨을 원함". Mirrors -// resolveRelationshipTier's structure exactly. Resolution order: -// contact-specific override (1:1 only) -> the sender's global TwinSettings -// default -> AutonomyL0 as the fail-safe fallback if nothing is set (L0 is -// the documented safe default everywhere else in this codebase -- never -// fail open to L1/L2). Group conversations always use the global default, -// same as relationship tier, since a group has more than one counterpart to -// pick a level for -- note this resolver only decides the L0/L1/L2 branch; -// the unconditional "group conversations never auto-send" hard block in +// resolveRelationshipTier's structure exactly (both share persona.go's +// findCounterpartContact lookup). Resolution order: contact-specific +// override (1:1 only) -> the sender's global TwinSettings default -> +// AutonomyL0 as the fail-safe fallback if nothing is set (L0 is the +// documented safe default everywhere else in this codebase -- never fail +// open to L1/L2). Group conversations always use the global default, same +// as relationship tier, since a group has more than one counterpart to pick +// a level for -- note this resolver only decides the L0/L1/L2 branch; the +// unconditional "group conversations never auto-send" hard block in // main.go's POST /conversations/:id/messages runs earlier and independent // of this function's result. func resolveAutonomyLevel(db *gorm.DB, actorID, conversationID uint) AutonomyLevel { - var conv Conversation - if err := db.First(&conv, conversationID).Error; err == nil && !conv.IsGroup { - var parts []ConversationParticipant - db.Where("conversation_id = ?", conversationID).Find(&parts) - for _, p := range parts { - if p.UserID == actorID { - continue - } - var contact Contact - err := db.Where("owner_user_id = ? AND contact_user_id = ?", actorID, p.UserID).First(&contact).Error - if err == nil && contact.AutonomyLevel != nil && validAutonomyLevel(*contact.AutonomyLevel) { - return *contact.AutonomyLevel - } - break + if contact, ok := findCounterpartContact(db, actorID, conversationID); ok { + if contact.AutonomyLevel != nil && validAutonomyLevel(*contact.AutonomyLevel) { + return *contact.AutonomyLevel } } diff --git a/core-backend/main.go b/core-backend/main.go index cb9ce70..14224ad 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -457,14 +457,20 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // tests, or a future anonymous path) still work exactly as before, // just without tier resolution (falls back to "formal"). tier := RelationshipFormal + note := "" if actor, ok := currentUser(c, db, false); ok { tier = resolveRelationshipTier(db, actor.ID, convID) + // 관계 메모 실제 반영(roadmap.md §2.7-E): unlike tier/autonomy + // there is no global default note, so group conversations (or no + // matching Contact) just resolve to "" here. + note = resolveRelationshipNote(db, actor.ID, convID) } started := time.Now() result, err := ai.requestDraft(draftRequest{ ContextLines: req.ContextLines, RelationshipTier: string(tier), + RelationshipNote: note, StyleExamples: req.StyleExamples, History: req.History, K: req.K, diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 67ac31a..c01d3cc 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -31,12 +31,13 @@ func mockAIService(t *testing.T) *httptest.Server { return } w.Header().Set("Content-Type", "application/json") - // 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. + // Echo relationship_tier and relationship_note into the response + // text (roadmap.md §2.7-B, §2.7-E) 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, " | "), + Text: "mock draft [tier=" + req.RelationshipTier + "] [note=" + req.RelationshipNote + "] for: " + strings.Join(req.ContextLines, " | "), }) case "/escalate/check": // Mirrors escalation_filter.py's rule set closely enough to diff --git a/core-backend/persona.go b/core-backend/persona.go index dd09062..c78c382 100644 --- a/core-backend/persona.go +++ b/core-backend/persona.go @@ -2,6 +2,34 @@ package main import "gorm.io/gorm" +// findCounterpartContact locates the Contact row (if any) representing the +// other person in a 1:1 conversation, from actorID's point of view. Shared +// by resolveRelationshipTier (§2.7-B), resolveAutonomyLevel (§2.7-D), and +// resolveRelationshipNote (§2.7-E) -- all three need the exact same +// "which Contact row applies to this actor+conversation" lookup and only +// differ in which field of the result they read. Returns ok=false for group +// conversations (more than one counterpart, so no single contact applies) +// or when no Contact row exists between actorID and the other participant. +func findCounterpartContact(db *gorm.DB, actorID, conversationID uint) (Contact, bool) { + var conv Conversation + if err := db.First(&conv, conversationID).Error; err != nil || conv.IsGroup { + return Contact{}, false + } + var parts []ConversationParticipant + db.Where("conversation_id = ?", conversationID).Find(&parts) + for _, p := range parts { + if p.UserID == actorID { + continue + } + var contact Contact + if err := db.Where("owner_user_id = ? AND contact_user_id = ?", actorID, p.UserID).First(&contact).Error; err == nil { + return contact, true + } + break + } + return Contact{}, false +} + // 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 @@ -9,20 +37,9 @@ import "gorm.io/gorm" // 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 + if contact, ok := findCounterpartContact(db, actorID, conversationID); ok { + if contact.RelationshipTier != nil && validRelationshipTier(*contact.RelationshipTier) { + return *contact.RelationshipTier } } @@ -32,3 +49,18 @@ func resolveRelationshipTier(db *gorm.DB, actorID, conversationID uint) Relation } return RelationshipFormal } + +// resolveRelationshipNote implements roadmap.md §2.7-E: Contact.RelationshipNote +// (a free-text note like "호칭: 자기야, 절대 언급 금지: 전 여친") already has full +// CRUD but never reached the draft prompt -- this makes it actually flow into +// ai-service. Unlike relationship tier / autonomy level, a note is inherently +// per-person: there is no "global default note" to fall back to. So this +// simply returns "" for group conversations, for a 1:1 with no matching +// Contact row, or for a Contact whose note is unset -- ai-service treats an +// empty note as "no extra instruction", identical to today's behavior. +func resolveRelationshipNote(db *gorm.DB, actorID, conversationID uint) string { + if contact, ok := findCounterpartContact(db, actorID, conversationID); ok { + return contact.RelationshipNote + } + return "" +} diff --git a/core-backend/persona_test.go b/core-backend/persona_test.go index 70d8aec..d4000ba 100644 --- a/core-backend/persona_test.go +++ b/core-backend/persona_test.go @@ -153,6 +153,98 @@ func TestDraftWithoutAuthDefaultsToFormalTier(t *testing.T) { } } +func TestContactRelationshipNoteReachesDraftRequest(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + RelationshipNote: "호칭: 자기야, 절대 언급 금지: 전 여친", + }) + 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, "[note=호칭: 자기야, 절대 언급 금지: 전 여친]") { + t.Fatalf("expected contact relationship note forwarded in mock draft, got %v", draftOut.Text) + } +} + +func TestDraftWithoutRelationshipNoteHasNoNoteText(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + // Contact exists but with no RelationshipNote set (empty string, the + // zero value) -- must resolve to "" rather than injecting anything. + postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + }) + + 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, "[note=]") { + t.Fatalf("expected empty note to produce no note text, got %v", draftOut.Text) + } +} + +func TestGroupConversationDraftAlwaysGetsEmptyNoteRegardlessOfContactNote(t *testing.T) { + server, _ := setupTestServer(t) + ownerID, ownerToken := mustSignup(t, server.URL, "민수") + peerID, _ := mustSignup(t, server.URL, "철수") + + // A 1:1 relationship note exists for this same pair of users elsewhere, + // but a group conversation has more than one counterpart -- there is no + // single "the note" to pick, so it must always resolve to "". + postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{ + DisplayName: "철수", + ContactUserID: &peerID, + RelationshipNote: "호칭: 자기야", + }) + + 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) + if !strings.Contains(draftOut.Text, "[note=]") { + t.Fatalf("expected group draft to always get empty note despite existing 1:1 contact note, got %v", draftOut.Text) + } +} + func TestGroupConversationDraftUsesGlobalTierNotContactOverride(t *testing.T) { server, _ := setupTestServer(t) ownerID, ownerToken := mustSignup(t, server.URL, "민수") diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 5fb9012..00d9b16 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -57,7 +57,12 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. 미배포) — `Contact.AutonomyLevel` 오버라이드 필드 + `resolveAutonomyLevel()`(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조)로 `POST /conversations/:id/messages`의 자율성 게이트 교체, `contacts_screen.dart`에 - `_AutonomyLevelPicker` 추가. C5(관계 메모 반영)·C6(답장 마감 알림)은 아직 todo. + `_AutonomyLevelPicker` 추가. C5(관계 메모 반영)도 **완료** (2026-08-03, 아직 GitHub `main`에만 + 있고 프로덕션 미배포) — `core-backend/aiservice.go` `draftRequest.RelationshipNote`, + `persona.go` `resolveRelationshipNote()`(그룹은 항상 빈 문자열 — 전역 기본 메모 개념 자체가 + 없어 티어/자율성과 다름), `ai-service/app/generation.py`가 메모를 `[관계 메모]` 프롬프트 + 문단으로 주입. 이 김에 세 resolver가 복붙하던 "1:1 상대 Contact 찾기" 루프를 + `findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)은 아직 todo. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 @@ -213,9 +218,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | **done** (2026-08-03) | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) | | **N4-C4b** | 자율성 레벨 해석 함수 + 발송 게이트 교체 | **done** (2026-08-03) | `core-backend/autonomy_resolve.go` `resolveAutonomyLevel()`(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조). `main.go` `POST /conversations/:id/messages`가 전역값만 읽던 부분을 이 함수 호출로 교체 — peer-veto·그룹차단·도배 감지·에스컬레이션 순서는 그대로, "level" 계산만 교체. 그룹 대화는 이 함수 자체도 전역 기본값만 쓰고, 그 전에 걸리는 무조건 차단과 이중으로 안전 | | **N4-C4c** | 연락처별 자율성 오버라이드 UI | **done** (2026-08-03) | `contacts_screen.dart` `_AutonomyLevelPicker`(`_RelationshipTierPicker` 옆, 기본값 사용/L0/L1/L2 4-way 칩), 연락처 목록 서브타이틀에도 표시 | -| **N4-C5a** | `draftRequest`에 `RelationshipNote` 필드 추가 | todo | `core-backend/aiservice.go` | -| **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | todo | `main.go` `POST /conversations/:id/draft`, 그룹은 스킵 | -| **N4-C5c** | 메모를 톤 프롬프트에 주입 | todo | `ai-service/app/generation.py`, 빈 값이면 무영향 | +| **N4-C5a** | `draftRequest`에 `RelationshipNote` 필드 추가 | **done** (2026-08-03) | `core-backend/aiservice.go`, `relationship_tier` 옆 `omitempty`, 빈 문자열 = 무영향 | +| **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | **done** (2026-08-03) | `main.go` `POST /conversations/:id/draft` + `core-backend/persona.go` `resolveRelationshipNote()`. 그룹은 항상 빈 문자열(전역 기본 메모 개념 자체가 없음, 티어/자율성과 다른 지점). `resolveRelationshipTier`/`resolveAutonomyLevel`과 공유하는 `findCounterpartContact()` 헬퍼로 3중 복붙 제거 | +| **N4-C5c** | 메모를 톤 프롬프트에 주입 | **done** (2026-08-03) | `ai-service/app/generation.py` `system_prompt_for_tier(relationship_tier, relationship_note)`가 `"[관계 메모] {note} -- ..."` 문단 추가(빈 값/`None`이면 무영향, 관계 티어 지침과 별도 문단이라 안 섞임). 에스컬레이션/정체성 게이팅에는 영향 없음 | | **N4-C6a** | "이따 답장" 스누즈 저장 | todo | 온디바이스 우선 원칙에 맞는 저장 위치 결정 | | **N4-C6b** | 리마인드 로컬 알림(본인에게만) | todo | 다른 사람에게 노출되지 않음 | | **N4-C6c** | 스누즈 표시/취소 UI | todo | `chat_screen.dart`/`conversation_list_screen.dart` | @@ -280,9 +285,10 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** (2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가 - 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03). 남은 것: Track C5(관계 메모 - 반영)·C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA - (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4는 아직 GitHub `main`에만 있음) + 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~ + **done** (2026-08-03). 남은 것: Track C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** → + N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4/C5는 아직 GitHub + `main`에만 있음) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3a67e03..d29b680 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -214,12 +214,23 @@ **2.7-E 관계 메모 실제 반영** (`PRD.md` §3.2 P1, `Contact.RelationshipNote` 필드·CRUD는 이미 있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는 -스텁, 2026-08-03 발견) -- [ ] `core-backend/aiservice.go`의 `draftRequest`에 `RelationshipNote string` 필드 추가 -- [ ] `POST /conversations/:id/draft` 핸들러가 연락처의 `RelationshipNote`를 조회해 요청에 포함 - (그룹 대화는 상대가 여럿이라 관계별 페르소나와 동일하게 스킵하거나 대표 로직 결정 필요) -- [ ] `ai-service/app/generation.py`가 메모가 있으면 시스템 프롬프트에 "호칭/금기어" 지침으로 - 주입(빈 문자열이면 기존과 동일하게 무영향) +스텁, 2026-08-03 발견 — **완료** 2026-08-03) +- [x] `core-backend/aiservice.go`의 `draftRequest`에 `RelationshipNote string` 필드 추가 + (`relationship_tier` 옆, 빈 문자열 = 메모 없음 = ai-service 프롬프트 무영향, `omitempty`) +- [x] `POST /conversations/:id/draft` 핸들러가 연락처의 `RelationshipNote`를 조회해 요청에 포함 — + `core-backend/persona.go`의 `resolveRelationshipNote()`. 티어/자율성과 달리 메모는 순전히 + 개인별이라 "전역 기본 메모" 개념 자체가 없음 — 그룹 대화(상대가 여럿)나 매칭되는 `Contact`가 + 없는 경우 그냥 빈 문자열로 귀결(관계 티어의 "전역 기본값 폴백"과 다른 지점). 이 참에 + `resolveRelationshipTier`/`resolveAutonomyLevel`/`resolveRelationshipNote` 셋이 거의 동일한 + "1:1 대화에서 상대방의 Contact 행 찾기" 루프를 각자 복붙하고 있던 걸 `persona.go`의 + `findCounterpartContact(db, actorID, conversationID) (Contact, bool)` 공용 헬퍼로 추출해서 + 셋 다 이걸 호출하도록 정리(중복 제거, 그룹 판정 로직도 한 곳에만 존재) +- [x] `ai-service/app/generation.py`가 메모가 있으면 시스템 프롬프트에 "호칭/금기어" 지침으로 + 주입(빈 문자열/`None`이면 기존과 동일하게 무영향) — `system_prompt_for_tier(relationship_tier, + relationship_note)`에 `"\n\n[관계 메모] {note} -- 위 내용을 참고해 호칭/금기어 등을 지켜라."` + 형태로 추가. 관계 티어(가까운/공식적) 지침과 분리된 별도 문단이라 서로 안 섞임. `draft_reply()`도 + `relationship_note` 파라미터를 받아 그대로 전달 — 에스컬레이션/정체성 게이팅 로직 이전 단계라 + 이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님) **2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드 전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견)