관계 메모 실제 반영(2.7-E) 구현: draftRequest에 RelationshipNote 전달 + 프롬프트 주입

Contact.RelationshipNote는 CRUD는 이미 있었지만 draftRequest에 필드 자체가 없어
ai-service 프롬프트에 전혀 전달되지 않는 저장 전용 스텁이었다. 이번 변경으로 실제
초안 생성에 반영되게 만든다.

- core-backend/aiservice.go: draftRequest에 RelationshipNote string 필드 추가
  (relationship_tier 옆, omitempty, 빈 문자열 = 무영향)
- core-backend/persona.go: resolveRelationshipNote() 추가. 티어/자율성과 달리
  메모는 순전히 개인별이라 전역 기본 메모 개념이 없음 -- 그룹 대화나 매칭되는
  Contact가 없으면 빈 문자열로 귀결. resolveRelationshipTier/resolveAutonomyLevel/
  resolveRelationshipNote 셋이 복붙하던 "1:1 상대 Contact 찾기" 루프를
  findCounterpartContact() 공용 헬퍼로 추출해 중복 제거
- core-backend/main.go: POST /conversations/:id/draft 핸들러가 resolveRelationshipNote
  결과를 draftRequest에 실어 보내도록 연결
- ai-service/app/generation.py: system_prompt_for_tier()가 relationship_note를
  받아 "[관계 메모] {note} -- ..." 문단을 관계 티어 지침과 별도로 추가(빈 값이면
  기존과 동일). draft_reply()도 파라미터 통과만 함 -- 에스컬레이션/정체성 게이팅은
  전혀 영향 없음
- ai-service/app/main.py: DraftRequest에 relationship_note 필드 추가, /draft가
  draft_reply로 그대로 전달
- 테스트: core-backend Go 64개(신규 3개: 메모 전달/빈 값/그룹 스킵) 전부 통과,
  ai-service pytest 52개(신규 5개) 전부 통과, mobile flutter analyze/test 변경 없이
  그대로 6개 통과(이 기능은 새 UI가 필요 없음 -- 필드는 이미 있었음)
- docs/roadmap.md §2.7-E, docs/deploy-checklist.md N4-C5a/b/c 완료 처리 + NOW/
  바로 다음 5개 요약 갱신

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
This commit is contained in:
Claude 2026-08-03 05:37:33 +00:00
parent b4a3b4a903
commit f9fba46889
No known key found for this signature in database
12 changed files with 321 additions and 60 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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"]

View File

@ -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

View File

@ -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"`

View File

@ -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
}
}

View File

@ -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,

View File

@ -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

View File

@ -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 ""
}

View File

@ -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, "민수")

View File

@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -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 발견)