Compare commits

...

7 Commits

Author SHA1 Message Date
Claude 95422bb012
답장 마감 알림(2.7-F) 구현: 온디바이스 스누즈 저장 + 인앱 배지 + 로컬 알림(부분 검증)
roadmap.md §2.7-F / deploy-checklist.md N4-C6a~c 대응. "이따 답장"을 누르면
대화방별 스누즈 시각을 순수 온디바이스 drift 테이블(ConversationSnoozes)에
저장하고(서버 전송 없음, 다른 사람에게 절대 보이지 않음), chat_screen.dart
앱바에서 빠른 선택(1시간 후/저녁에/내일)으로 걸고 해제할 수 있게 했다. 실제
답장을 보내면 자동으로 스누즈가 풀린다.

로컬 알림은 flutter_local_notifications+timezone을 실제로 붙여
zonedSchedule()/cancel()을 호출하지만, 이 샌드박스에는 실기기·에뮬레이터가
없어 알림이 실제로 울리는지는 검증하지 못했다 — 검증한 건 스케줄러 호출
인자(id/시각/페이로드)가 목(mock) 기준으로 맞는지뿐이다. 그 대신 항상 켜져
있는 인앱 대체 경로로 conversation_list_screen.dart 배지와 chat_screen.dart
배너가 마감 지난 스누즈를 보여주고 바로 해제할 수 있게 했다. 웹 빌드는
플러그인이 웹을 지원하지 않아 no-op 스텁으로 대체.

마감 판정(isSnoozePastDue)과 빠른 선택 시각 계산은 순수 함수로 분리해
고정 시각으로 단위 테스트했고, SnoozeController를 통해 스케줄러를 목으로
바꿔 넣어 호출 인자도 검증했다. 백엔드(core-backend/ai-service) 변경 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 05:55:30 +00:00
Claude f9fba46889
관계 메모 실제 반영(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
2026-08-03 05:37:33 +00:00
Claude b4a3b4a903
자율성 상대별 예외(2.7-D) 구현: 연락처별 AutonomyLevel 오버라이드 + 발송 게이트 교체
PRD.md §3.1 P0 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정" / §4 엣지케이스
대비 미구현 갭 해소. 관계별 페르소나(2.7-B)의 RelationshipTier 오버라이드 패턴을
그대로 미러링:

- core-backend/models.go: Contact.AutonomyLevel *AutonomyLevel 추가(nil = 전역
  기본값), validAutonomyLevel() 검증 헬퍼 추가(twin-settings PATCH의 인라인 검증도
  이 헬퍼로 통일)
- core-backend/autonomy_resolve.go: resolveAutonomyLevel() 신규 — 연락처 오버라이드
  (1:1 전용) → 전역 TwinSettings 기본값 → L0 순으로 해석. L1/L2가 아니라 L0으로
  폴백하는 이유는 이 코드베이스 전반의 안전 우선 기본값과 동일(불확실하면 항상 초안만
  생성, 사람이 직접 발송). 그룹 대화는 RelationshipTier와 동일하게 항상 전역 기본값만
  사용 — main.go의 그룹 대화 무조건 차단과 이중으로 안전
- core-backend/main.go: POST /conversations/:id/messages의 자율성 게이트가
  TwinSettings 전역값만 읽던 걸 resolveAutonomyLevel() 호출로 교체. peer-veto→그룹
  차단→도배 감지→에스컬레이션 순서와 각 하드게이트는 그대로 유지, "level" 계산
  방식만 바뀜
- core-backend/a1_a2_routes.go: createContactRequest/updateContactRequest에
  AutonomyLevel 필드 추가, contactJSON()에 포함, 생성/수정 핸들러가 RelationshipTier와
  동일한 전체 교체(full-replace) 시맨틱으로 처리(PATCH에서 필드 생략 시 nil로 리셋)
- core-backend/autonomy_resolve_test.go: 연락처 오버라이드 우선순위, 전역 기본값
  폴백, 그룹 대화는 오버라이드 무시, 잘못된 값 검증 거부, PATCH 전체 교체 리셋 커버
- mobile: models.dart에 Contact.autonomyLevel(nullable) 추가, api_client.dart
  createContact/updateContact에 선택적 autonomyLevel 파라미터 스레딩,
  contacts_screen.dart에 _AutonomyLevelPicker(_RelationshipTierPicker와 동일 구조)
  추가해 추가/수정 다이얼로그에 배치 + 연락처 목록 서브타이틀에 표시

ai-service는 변경 없음 — 자율성 레벨은 발송 게이트 로직일 뿐 초안 톤에 영향을
주지 않아 ai-service 프롬프트까지 전달될 필요가 없음.

테스트: go test ./... 61개 전부 PASS, flutter analyze/test 클린(기존 무관 info
린트 1건 제외).

docs/roadmap.md §2.7-D, docs/deploy-checklist.md N4-C4a/b/c를 done으로 갱신하고
NOW/바로 다음 5개 요약도 동기화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 05:30:42 +00:00
Claude 4182eba9a6
docs: PRD 2차 갭 분석 체크리스트 추가 (2.7-D~F / N4-C4~C6)
roadmap.md §2.7-A~C(단톡 따라잡기·관계별 페르소나·스팸 감지) 완료 후 PRD.md 재대조로
발견한 잔여 갭 3건을 체크리스트로 등록. 우선순위: D(자율성 상대별 예외, P0 명시 항목인데
오버라이드 메커니즘 자체가 없음) → E(관계 메모 반영, 필드/UI는 있으나 프롬프트 주입 안 됨) →
F(답장 마감 알림, P1이자 완전 미구현). deploy-checklist.md에도 동일 항목을 N4-C4/C5/C6로
동기화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 05:23:03 +00:00
Claude a8c7b98922
스팸/도배 감지 최소 버전(2.7-C) 구현: 대화방 단위 자동 응대 일시중단 + 재개
PRD.md §4 엣지케이스가 "안전 관련이라 v1 최소 버전 필요"로 명시한 항목(P0)인데
관련 로직이 0건이었던 마지막 콘텐츠 갭(roadmap.md §2.7-C, deploy-checklist.md
N4-C3a/b)을 채운다. Track C(콘텐츠 갭) A/B/C 전체 완료.

- core-backend/flood_detect.go(신규): floodMessageThreshold=5건 /
  floodWindow=2분 상수 + floodDetected()/floodReason(). 안전을 위해 반드시
  있어야 하는 기술적 최소값이라 명시적으로 placeholder로 남기고 구현했다 —
  roadmap.md §3의 "PoC 결과가 있어야 정할 수 있는 것"(자율성 기본값 등 UX
  기본값)과는 성격이 다름. 카운트는 대화방 내 sender_id != 소유자인 메시지만
  집계(트윈 자동발송·소유자 본인 발송은 소유자 ID로 남으므로 자동 제외).
- core-backend/main.go: POST /conversations/:id/messages 하드게이트에
  peer-veto → 그룹 대화 차단 다음, 에스컬레이션 체크 이전 지점으로 추가 —
  트윈 자동발송 시도가 전부 지나가는 동일한 우회 불가 지점. 이미
  TwinDisabledByFlood가 켜져 있으면 재계산 없이 즉시 차단(중복 로그/쿼리
  방지). 새로 임계치를 넘기면 대화방을 영구 차단하고 EscalationLog 기록 +
  notifyUser 푸시.
- core-backend/models.go: Conversation.TwinDisabledByFlood 필드 추가.
  TwinDisabledByPeer(거부권, 사람의 일방적 선택 — v1엔 되돌리기 API 없음)와
  달리 이건 시스템이 자동으로 취하는 조치라서 AGENTS.md "every automatic
  action needs post-hoc notification + one-tap undo"가 그대로 적용됨 —
  POST /conversations/:id/flood-reset로 되돌릴 수 있게 별도 필드로 분리.
- core-backend/a1_a2_routes.go: GET /conversations 응답에
  twin_disabled_by_flood 추가(twin_disabled_by_peer와 동일한 자리).
- core-backend/flood_detect_test.go(신규): 임계치 경계값(정확히 N건은
  통과, N+1건은 차단), 사람 발송은 게이트 안 걸림, 윈도우 밖 과거 메시지는
  집계 제외, flood-reset으로 재개, 존재하지 않는 대화방 404 — 5개 테스트.
- mobile/lib/models/models.dart, services/api_client.dart: 모델·API
  클라이언트에 twinDisabledByFlood/resetFlood() 추가.
- mobile/lib/screens/conversation_list_screen.dart: 대화 목록에 도배 차단
  배지(거부권과 같은 자리, 다른 아이콘/문구).
- mobile/lib/screens/chat_screen.dart: 채팅방을 열면(목록에서 넘어온 초기
  상태) 또는 발송 시도가 다시 차단되면 배너에 "자동응대 재개" 버튼을 보여줌
  — one-tap undo. 새 알림 메커니즘을 만들지 않고 기존 EscalationLog/
  InboxScreen을 그대로 재사용.
- mobile/test/models_test.dart: twin_disabled_by_flood 파싱 테스트 추가.
- docs/roadmap.md §2.7-C, docs/deploy-checklist.md N4-C3a/b·Track C 요약·
  "바로 다음 5개"를 완료로 갱신.

테스트: go test ./... 51/51, python3 -m pytest tests/ -q 47/47,
flutter analyze 클린 + flutter test 5/5 모두 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 05:12:29 +00:00
Claude 73dfa2c33c
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docs/deploy-checklist.md
2026-08-03 05:00:58 +00:00
Claude 5733ec4399
관계별 페르소나(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
2026-08-03 04:59:34 +00:00
40 changed files with 3094 additions and 241 deletions

View File

@ -47,6 +47,29 @@ 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, 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
def build_user_prompt(style_examples, context_lines):
examples = "\n".join(f"- {s}" for s in style_examples)
context = "\n".join(context_lines)
@ -61,12 +84,26 @@ def last_incoming_text(context_lines):
return last.split(": ", 1)[1] if ": " in last else last
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
def draft_reply(
style_examples,
context_lines,
model="gemini-2.5-flash",
api_key=None,
relationship_tier=None,
relationship_note=None,
):
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft.
relationship_tier: "close" | "formal" | None (roadmap.md §2.7-B). Only
nudges tone -- escalation/identity gating above is unaffected by it.
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)
@ -89,6 +126,9 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key
resp = client.models.generate_content(
model=model,
contents=build_user_prompt(style_examples, context_lines),
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
config=types.GenerateContentConfig(
system_instruction=system_prompt_for_tier(relationship_tier, relationship_note),
max_output_tokens=300,
),
)
return "ok", resp.text.strip()

View File

@ -30,6 +30,8 @@ class DraftRequest(BaseModel):
history: Optional[List[str]] = None
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):
@ -71,7 +73,13 @@ def draft(req: DraftRequest):
req.history, last_incoming_text(req.context_lines), k=req.k
)
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
status, text = draft_reply(
style_examples,
req.context_lines,
model=req.model,
relationship_tier=req.relationship_tier,
relationship_note=req.relationship_note,
)
return DraftResponse(status=status, text=text)

View File

@ -6,7 +6,14 @@ import sys
import types
from unittest.mock import MagicMock
from app.generation import build_user_prompt, draft_reply, last_incoming_text
from app.generation import (
RELATIONSHIP_TIER_INSTRUCTIONS,
SYSTEM_PROMPT,
build_user_prompt,
draft_reply,
last_incoming_text,
system_prompt_for_tier,
)
def test_last_incoming_text_strips_speaker_prefix():
@ -73,3 +80,97 @@ def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch):
_, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash"
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]
def test_system_prompt_for_tier_none_returns_base():
assert system_prompt_for_tier(None) == SYSTEM_PROMPT
def test_system_prompt_for_tier_close_adds_instruction():
prompt = system_prompt_for_tier("close")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt
def test_system_prompt_for_tier_formal_adds_instruction():
prompt = system_prompt_for_tier("formal")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in prompt
def test_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 = "네 알겠습니다"
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"]
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

@ -2,6 +2,7 @@
/escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
from fastapi.testclient import TestClient
import app.main as main_module
from app.main import app
client = TestClient(app)
@ -105,3 +106,83 @@ def test_summarize_no_key(monkeypatch):
body = resp.json()
assert body["status"] == "no_key"
assert "토요일 모임 3시로 하자" in body["summary"]
def test_draft_passes_relationship_tier_through_to_draft_reply(monkeypatch):
captured = {}
def fake_draft_reply(
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=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, relationship_note=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
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

@ -18,12 +18,20 @@ type createContactRequest struct {
DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"`
// RelationshipTier overrides the owner's global default for this
// contact (roadmap.md §2.7-B). Nil = use the global default.
RelationshipTier *RelationshipTier `json:"relationship_tier"`
// AutonomyLevel overrides the owner's global default autonomy level for
// this contact (roadmap.md §2.7-D). Nil = use the global default.
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
}
type updateContactRequest struct {
DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"`
RelationshipTier *RelationshipTier `json:"relationship_tier"`
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
}
func contactJSON(ct Contact) gin.H {
@ -32,6 +40,8 @@ func contactJSON(ct Contact) gin.H {
"display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote,
"relationship_tier": ct.RelationshipTier,
"autonomy_level": ct.AutonomyLevel,
}
}
@ -209,6 +219,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
"id": conv.ID,
"is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
"twin_disabled_by_flood": conv.TwinDisabledByFlood,
"user_ids": participantIDs,
"created_at": conv.CreatedAt,
"unread_count": unreadCount,
@ -260,11 +271,21 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
contact := Contact{
OwnerUserID: userID,
ContactUserID: req.ContactUserID,
DisplayName: req.DisplayName,
RelationshipNote: req.RelationshipNote,
RelationshipTier: req.RelationshipTier,
AutonomyLevel: req.AutonomyLevel,
}
if err := db.Create(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
@ -307,6 +328,14 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
var contact Contact
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
@ -319,6 +348,8 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
contact.DisplayName = req.DisplayName
contact.ContactUserID = req.ContactUserID
contact.RelationshipNote = req.RelationshipNote
contact.RelationshipTier = req.RelationshipTier
contact.AutonomyLevel = req.AutonomyLevel
if err := db.Save(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return

View File

@ -20,6 +20,15 @@ func newAIServiceClient() *AIServiceClient {
type draftRequest struct {
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"`
// 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

@ -0,0 +1,32 @@
package main
import "gorm.io/gorm"
// resolveAutonomyLevel implements roadmap.md §2.7-D: a user may want L2
// (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 (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 {
if contact, ok := findCounterpartContact(db, actorID, conversationID); ok {
if contact.AutonomyLevel != nil && validAutonomyLevel(*contact.AutonomyLevel) {
return *contact.AutonomyLevel
}
}
var settings TwinSettings
if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validAutonomyLevel(settings.AutonomyLevel) {
return settings.AutonomyLevel
}
return AutonomyL0
}

View File

@ -0,0 +1,229 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"testing"
)
// TestContactAutonomyLevelOverridesGlobalDefaultInSendGate mirrors
// TestContactRelationshipTierOverridesGlobalDefaultInDraft: global default
// stays at the signup default (L0, which blocks all twin auto-send), but a
// per-contact override raises it to L2 for this specific counterpart, so a
// whitelisted twin message goes through without requiring approval.
func TestContactAutonomyLevelOverridesGlobalDefaultInSendGate(t *testing.T) {
server, db := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
if contactResp.StatusCode != http.StatusOK {
t.Fatalf("create contact: %d", contactResp.StatusCode)
}
db.Create(&WhitelistRule{UserID: ownerID, TopicKeyword: "ㅇㅇ"})
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))
// Global default is still L0 (never changed) -- without the contact
// override this would 403. Approved is false and Text matches the
// whitelist keyword, which only clears the gate at L2.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 (contact override L2 + whitelist match), got %d", resp.StatusCode)
}
}
// TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride mirrors
// TestDraftFallsBackToGlobalTierWithoutContactOverride: no contact record at
// all -- resolveAutonomyLevel must fall back to the sender's global
// TwinSettings.AutonomyLevel rather than erroring or defaulting to L0 when a
// non-L0 global level was explicitly set.
func TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL1)
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))
// L1 without approval must still block.
blocked := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for unapproved L1 send, got %d", blocked.StatusCode)
}
// L1 with approval must go through -- confirms the global default (not
// some stray L0 fallback) is really what got resolved.
approved := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin, Approved: true,
})
if approved.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for approved L1 send using global default, got %d", approved.StatusCode)
}
}
// TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride
// verifies resolveAutonomyLevel's own group-conversation branch directly
// (the unconditional "no twin auto-send in groups" hard block in main.go
// already stops group sends earlier and independently -- this test is about
// resolveAutonomyLevel's resolution order, mirroring
// TestGroupConversationDraftUsesGlobalTierNotContactOverride).
func TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride(t *testing.T) {
server, db := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
// Global default is still the signup default (L0). Even though the
// contact has an L2 override, a group conversation must never pick it up.
got := resolveAutonomyLevel(db, ownerID, convID)
if got != AutonomyL0 {
t.Fatalf("expected group conversation to resolve global default L0 ignoring contact override, got %v", got)
}
}
// TestDraftGroupConversationHardBlockStillWinsOverAutonomyLevel is a smoke
// check that the pre-existing "no twin auto-send in groups" hard block
// (roadmap.md §2.7-A) still runs before -- and independent of -- the
// autonomy gate, even when a contact override would otherwise allow L2.
func TestGroupConversationSendStillBlockedRegardlessOfContactAutonomyOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "그룹에서 자동발송 시도", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for group twin auto-send regardless of contact autonomy override, got %d", resp.StatusCode)
}
}
func TestResolveAutonomyLevelFallsBackToL0WithNoSettingsAtAll(t *testing.T) {
server, db := setupTestServer(t)
// A conversation with a sender that never signed up / has no
// TwinSettings row at all must fail closed to L0, not error out.
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
_ = server
got := resolveAutonomyLevel(db, 999999, conv.ID)
if got != AutonomyL0 {
t.Fatalf("expected fail-safe default L0 for unknown user, got %v", got)
}
}
func TestCreateContactRejectsInvalidAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
bad := AutonomyLevel("L9")
resp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "이상함",
AutonomyLevel: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 creating contact with invalid autonomy_level, got %d", resp.StatusCode)
}
}
func TestUpdateContactRejectsInvalidAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "친구",
})
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
contactID := uint(created["id"].(float64))
bad := AutonomyLevel("nope")
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{
DisplayName: "친구",
AutonomyLevel: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 updating contact with invalid autonomy_level, got %d", resp.StatusCode)
}
}
// TestUpdateContactFullReplaceResetsAutonomyLevelOverride confirms Contact
// PATCH is full-replace (like every other Contact field, unlike TwinSettings
// PATCH which is conditional/partial) -- omitting autonomy_level on a later
// PATCH resets the override back to nil (use the global default), it does
// not leave the previous override in place.
func TestUpdateContactFullReplaceResetsAutonomyLevelOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
contactID := uint(created["id"].(float64))
if created["autonomy_level"] != "L2" {
t.Fatalf("expected autonomy_level L2 right after create, got %v", created["autonomy_level"])
}
// Update without autonomy_level in the request body -- full-replace
// semantics mean this must reset it to nil, exactly like
// relationship_tier does today.
updateResp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
})
if updateResp.StatusCode != http.StatusOK {
t.Fatalf("update contact: %d", updateResp.StatusCode)
}
var updated map[string]interface{}
json.NewDecoder(updateResp.Body).Decode(&updated)
if updated["autonomy_level"] != nil {
t.Fatalf("expected autonomy_level to reset to nil after omitting it on PATCH, got %v", updated["autonomy_level"])
}
}

View File

@ -0,0 +1,57 @@
package main
import (
"fmt"
"time"
"gorm.io/gorm"
)
// Flood/spam detection minimum version (roadmap.md §2.7-C, PRD.md §4
// 엣지케이스: "상대가 짧은 시간에 메시지 도배 -> 스팸 감지 임계치 초과 시 응대
// 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요)").
//
// These two constants are a conservative, clearly-labeled v1-minimum
// placeholder, NOT a PoC-validated number -- unlike the autonomy-default/
// whitelist-default questions in roadmap.md §3 ("PoC 결과가 있어야 정할 수
// 있는 것"), this is a technical safety minimum that has to exist for P0
// coverage, so it's fine to ship a placeholder and revisit with real usage
// data later rather than leaving the gate unbuilt.
const (
// floodMessageThreshold is how many incoming messages from the
// counterpart within floodWindow count as "도배".
floodMessageThreshold = 5
// floodWindow is the trailing window floodMessageThreshold is counted over.
floodWindow = 2 * time.Minute
)
// floodIncomingCount counts messages in conversationID sent by anyone other
// than ownerUserID (i.e. the counterpart's own words, not the twin's
// auto-sends nor the owner's own human-typed messages, both of which use
// SenderID == ownerUserID -- see sendMessageRequest.SenderID) within the
// trailing floodWindow. Retracted messages still count: retraction undoes an
// auto-send the twin/owner made, it says nothing about whether the peer was
// flooding.
func floodIncomingCount(db *gorm.DB, conversationID, ownerUserID uint) int64 {
var count int64
db.Model(&Message{}).
Where("conversation_id = ? AND sender_id != ? AND created_at >= ?",
conversationID, ownerUserID, time.Now().Add(-floodWindow)).
Count(&count)
return count
}
// floodDetected reports whether the counterpart has crossed
// floodMessageThreshold within floodWindow for this conversation, plus the
// count for the resulting EscalationLog reason string.
func floodDetected(db *gorm.DB, conversationID, ownerUserID uint) (bool, int64) {
count := floodIncomingCount(db, conversationID, ownerUserID)
return count > floodMessageThreshold, count
}
// floodReason formats the Korean EscalationLog copy for a flood auto-pause,
// matching the tone of the existing escalation_filter reasons.
func floodReason(count int64) string {
return fmt.Sprintf("도배 감지: 최근 %d분 동안 상대로부터 메시지 %d건 수신 (임계치 %d건) -- 자동응대 일시중단",
int(floodWindow/time.Minute), count, floodMessageThreshold)
}

View File

@ -0,0 +1,204 @@
package main
import (
"net/http"
"strconv"
"testing"
"time"
"gorm.io/gorm"
)
// seedIncomingMessages inserts n messages from a counterpart (any sender_id
// != ownerID) into conv, timestamped now, simulating a peer flooding the
// conversation. Uses db.Create directly (like the peer-veto tests above)
// rather than going through POST /messages, since that endpoint's gating
// only applies to sender_mode=twin and these are meant to be plain incoming
// human messages from the other party.
func seedIncomingMessages(t *testing.T, db *gorm.DB, conv Conversation, ownerID uint, n int) {
t.Helper()
peerID := ownerID + 1000 // any id distinct from the owner
for i := 0; i < n; i++ {
db.Create(&Message{
ConversationID: conv.ID,
SenderID: peerID,
SenderMode: SenderHuman,
Text: "spam " + strconv.Itoa(i),
})
}
}
func TestFloodDetectionBlocksTwinAutoSendAfterThresholdExceeded(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "도배테스트")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "ㅇㅇ"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// Below threshold: the counterpart sending floodMessageThreshold messages
// exactly must NOT trip the gate yet (strictly greater-than semantics).
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold)
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at exactly the threshold (not yet exceeded), got %d", resp.StatusCode)
}
// One more incoming message tips it over the threshold.
seedIncomingMessages(t, db, conv, senderID, 1)
blocked := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 또 왔어", SenderMode: SenderTwin, Approved: true,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 once flood threshold is exceeded, got %d", blocked.StatusCode)
}
var conv2 Conversation
if err := db.First(&conv2, conv.ID).Error; err != nil {
t.Fatalf("reload conversation: %v", err)
}
if !conv2.TwinDisabledByFlood {
t.Fatal("expected twin_disabled_by_flood to be set after flood detection")
}
var logs []EscalationLog
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 {
t.Fatalf("expected exactly one EscalationLog row for the flood block, got %d", len(logs))
}
// The gate must stay closed on a subsequent attempt without re-counting
// (also prevents duplicate EscalationLog rows piling up per attempt).
again := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 세번째", SenderMode: SenderTwin, Approved: true,
})
if again.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 to persist once flood-blocked, got %d", again.StatusCode)
}
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 {
t.Fatalf("expected the flood EscalationLog to stay at 1 row, got %d", len(logs))
}
}
func TestFloodDetectionDoesNotBlockHumanMessages(t *testing.T) {
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "도배사람")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+5)
// The owner's own human-authored message is never gated, flood or
// otherwise -- it's their own words.
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "괜찮아?", SenderMode: SenderHuman,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("flood detection must not block human-authored messages, got %d", resp.StatusCode)
}
}
func TestFloodDetectionOnlyCountsMessagesWithinWindow(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "옛도배")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// Stale messages from outside floodWindow must not count toward the
// threshold, even though there are plenty of them.
peerID := senderID + 1000
stale := time.Now().Add(-floodWindow * 3)
for i := 0; i < floodMessageThreshold+10; i++ {
db.Create(&Message{
ConversationID: conv.ID,
SenderID: peerID,
SenderMode: SenderHuman,
Text: "old spam",
CreatedAt: stale,
})
}
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("stale messages outside floodWindow must not trigger the gate, got %d", resp.StatusCode)
}
}
func TestFloodResetReenablesTwinAutoSend(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "재개테스트")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+1)
blocked := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 once flooded, got %d", blocked.StatusCode)
}
// One-tap undo (AGENTS.md "every automatic action needs post-hoc
// notification + one-tap undo") -- unlike peer veto, this auto-pause
// must be reversible.
resetResp := postJSON(t, convBase+"/flood-reset", nil)
if resetResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 resetting flood block, got %d", resetResp.StatusCode)
}
var conv2 Conversation
if err := db.First(&conv2, conv.ID).Error; err != nil {
t.Fatalf("reload conversation: %v", err)
}
if conv2.TwinDisabledByFlood {
t.Fatal("expected twin_disabled_by_flood to be cleared after flood-reset")
}
// The flood messages that tripped the gate are still inside floodWindow
// right after reset, so without clearing them a resend would immediately
// re-trip the same detection (correct fail-safe behavior, but not what
// this test is checking) -- simulate the message rate having actually
// dropped, which is the case flood-reset is meant for.
db.Where("conversation_id = ?", conv.ID).Delete(&Message{})
resent := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 다시", SenderMode: SenderTwin, Approved: true,
})
if resent.StatusCode != http.StatusOK {
t.Fatalf("expected 200 after flood-reset re-enabled auto-send, got %d", resent.StatusCode)
}
}
func TestFloodResetMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/flood-reset", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}

View File

@ -35,6 +35,9 @@ type sendMessageRequest struct {
type updateTwinSettingsRequest struct {
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
// RelationshipTier is optional so existing autonomy-only PATCH calls
// keep working unchanged (roadmap.md §2.7-B). Nil = leave it as-is.
RelationshipTier *RelationshipTier `json:"relationship_tier"`
}
type createWhitelistRuleRequest struct {
@ -202,7 +205,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal})
if !demo {
now := time.Now()
@ -254,8 +257,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// words and are never gated. On any doubt (AI service unreachable
// or erroring) we fail closed and block the send. Peer veto is
// checked first (it's a total kill switch for this conversation,
// independent of content), then escalation, then autonomy level --
// none of the later checks can override an earlier block.
// independent of content), then the group-chat block, then flood
// detection, then escalation, then autonomy level -- none of the
// later checks can override an earlier block.
if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked()
@ -273,6 +277,41 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return
}
// 스팸/도배 감지 최소 버전(PRD.md §4, roadmap.md §2.7-C): 이미
// 도배로 중단된 대화방이면 재검사 없이 바로 막는다 (중복
// EscalationLog 방지 + 매 시도마다 카운트 쿼리를 다시 돌리지
// 않기 위함). 아직 중단되지 않았다면 이번 전송을 계기로 트리거를
// 검사한다 -- 이 게이트는 에스컬레이션 하드게이트와 같은 우회
// 불가 지점에 있어, 어떤 자율성 레벨/화이트리스트로도 건너뛸 수
// 없다.
if conversation.TwinDisabledByFlood {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 이 대화방의 와카뷰 자동 발송이 일시중단되어 있습니다 -- 사후 알림에서 확인 후 재개할 수 있습니다"})
return
}
if exceeded, count := floodDetected(db, convID, req.SenderID); exceeded {
conversation.TwinDisabledByFlood = true
db.Save(&conversation)
runtimeMetrics.recordTwinBlocked()
reason := floodReason(count)
db.Create(&EscalationLog{
UserID: req.SenderID,
ConversationID: convID,
Reason: reason,
MessageSnippet: req.Text,
})
// Automatic action -> post-hoc notification (AGENTS.md
// absolute safety invariants), same as the escalation gate
// below. Best-effort push (no-op without FCM_SERVER_KEY /
// real tokens).
_, _, _ = notifyUser(db, req.SenderID, "와카뷰 자동응대 일시중단", reason, map[string]string{
"type": "flood",
"conversation_id": strconv.FormatUint(uint64(convID), 10),
})
c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 자동 발송이 일시중단되었습니다", "reason": reason})
return
}
result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err)
if err != nil {
@ -299,12 +338,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
}
// Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing
// settings fail closed to L0, the documented default.
level := AutonomyL0
var settings TwinSettings
if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil {
level = settings.AutonomyLevel
}
// settings fail closed to L0, the documented default. roadmap.md
// §2.7-D: the level itself can be overridden per-contact (1:1
// only -- group conversations already returned above and never
// reach this point), so resolve it the same way relationship
// tier is resolved instead of reading only the global default.
level := resolveAutonomyLevel(db, req.SenderID, convID)
switch level {
case AutonomyL0:
@ -413,9 +452,25 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return
}
// 관계별 페르소나(roadmap.md §2.7-B): optional auth -- this endpoint
// has never required a session, so callers without one (existing
// tests, or a future anonymous path) still work exactly as before,
// just without tier resolution (falls back to "formal").
tier := RelationshipFormal
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,
@ -454,6 +509,32 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true})
})
// 도배 감지로 자동 발송이 일시중단된 대화방을 다시 켠다 (roadmap.md
// §2.7-C, AGENTS.md "every automatic action needs post-hoc notification +
// one-tap undo"). 거부권(veto)과 달리 이 중단은 사람의 결정이 아니라
// 시스템이 자동으로 취한 조치라서, 되돌리기 경로가 반드시 있어야 한다 --
// 거부권처럼 영구적으로 막아두지 않는다.
r.POST("/conversations/:id/flood-reset", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
var conversation Conversation
if err := db.First(&conversation, convID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"})
return
}
conversation.TwinDisabledByFlood = false
if err := db.Save(&conversation).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_flood": false})
})
r.PATCH("/users/:id/twin-settings", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
@ -468,10 +549,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if req.AutonomyLevel != AutonomyL0 && req.AutonomyLevel != AutonomyL1 && req.AutonomyLevel != AutonomyL2 {
if !validAutonomyLevel(req.AutonomyLevel) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
var settings TwinSettings
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
@ -479,12 +564,19 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return
}
settings.AutonomyLevel = req.AutonomyLevel
if req.RelationshipTier != nil {
settings.RelationshipTier = *req.RelationshipTier
}
if err := db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"autonomy_level": settings.AutonomyLevel,
"relationship_tier": settings.RelationshipTier,
})
})
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {

View File

@ -31,7 +31,14 @@ func mockAIService(t *testing.T) *httptest.Server {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
// Echo relationship_tier 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 + "] [note=" + req.RelationshipNote + "] for: " + strings.Join(req.ContextLines, " | "),
})
case "/escalate/check":
// Mirrors escalation_filter.py's rule set closely enough to
// exercise core-backend's gating logic; the rules themselves

View File

@ -17,6 +17,27 @@ const (
AutonomyL2 AutonomyLevel = "L2"
)
func validAutonomyLevel(l AutonomyLevel) bool {
return l == AutonomyL0 || l == AutonomyL1 || l == AutonomyL2
}
// RelationshipTier is the minimum-2-tier persona split roadmap.md §2.7-B /
// PRD.md §2.1-②/§3.1 requires: draft tone should read differently for a
// close friend than for someone you'd stay formal with. Defaults to the
// more conservative "formal" tier for anyone who hasn't set this explicitly
// (same fail-safe-leaning default philosophy as AutonomyLevel defaulting
// to L0 when missing).
type RelationshipTier string
const (
RelationshipClose RelationshipTier = "close"
RelationshipFormal RelationshipTier = "formal"
)
func validRelationshipTier(t RelationshipTier) bool {
return t == RelationshipClose || t == RelationshipFormal
}
type User struct {
ID uint `gorm:"primaryKey"`
InviteCode string `gorm:"uniqueIndex;not null"`
@ -55,6 +76,16 @@ type Contact struct {
ContactUserID *uint
DisplayName string `gorm:"not null"`
RelationshipNote string
// RelationshipTier overrides the owner's global TwinSettings.RelationshipTier
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
// "use the global default".
RelationshipTier *RelationshipTier
// AutonomyLevel overrides the owner's global TwinSettings.AutonomyLevel
// for auto-send gating in 1:1 conversations with this specific contact
// (roadmap.md §2.7-D, PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Nil
// means "use the global default". Same nullable-override shape as
// RelationshipTier above.
AutonomyLevel *AutonomyLevel
CreatedAt time.Time
}
@ -63,10 +94,17 @@ type Contact struct {
// it lives here rather than on Contact. Set by POST /conversations/:id/veto
// when the counterpart asks to talk to the human only; checked before any
// twin auto-send in that conversation (main.go).
// TwinDisabledByFlood is the flood/spam auto-pause flag (roadmap.md §2.7-C,
// PRD.md §4 "스팸 감지 임계치 초과 시 응대 중단"). Unlike TwinDisabledByPeer
// (a deliberate one-way human/peer choice, no un-veto endpoint by design),
// this is a fully automatic system action, so AGENTS.md's "every automatic
// action needs post-hoc notification + one-tap undo" applies -- it's
// reversible via POST /conversations/:id/flood-reset, unlike peer veto.
type Conversation struct {
ID uint `gorm:"primaryKey"`
IsGroup bool `gorm:"not null;default:false"`
TwinDisabledByPeer bool `gorm:"not null;default:false"`
TwinDisabledByFlood bool `gorm:"not null;default:false"`
CreatedAt time.Time
}
@ -98,6 +136,7 @@ type TwinSettings struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex;not null"`
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
RelationshipTier RelationshipTier `gorm:"not null;default:formal"`
CreatedAt time.Time
UpdatedAt time.Time
}

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

@ -0,0 +1,66 @@
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
// 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 {
if contact, ok := findCounterpartContact(db, actorID, conversationID); ok {
if contact.RelationshipTier != nil && validRelationshipTier(*contact.RelationshipTier) {
return *contact.RelationshipTier
}
}
var settings TwinSettings
if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validRelationshipTier(settings.RelationshipTier) {
return settings.RelationshipTier
}
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

@ -0,0 +1,274 @@
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 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, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
closeTier := RelationshipClose
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipTier: &closeTier,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"철수: 이번 주 토요일 모임 어때?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
// Group conversations ignore the per-contact override (roadmap.md
// §2.7-B: a group has more than one counterpart) and use the global
// default ("formal") instead.
if !strings.Contains(draftOut.Text, "tier=formal") {
t.Fatalf("expected group draft to use global tier=formal, got %v", draftOut.Text)
}
}

View File

@ -43,8 +43,37 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
다크 캔버스 `#141418` · web/core/ai 재배포 완료
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료·프로덕션 반영** (2026-08-03) —
그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커,
안 본 배지, 그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지).
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
안 본 배지, 그룹 트윈 발송 서버측 차단까지. C2(관계별 페르소나)도 **완료**
(2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포) — `relationship_tier`
전역 기본값+연락처별 오버라이드, 초안 생성 톤 프롬프트 분기. C3(스팸/도배 감지)도
**완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포) —
`core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`,
안전 최소값 placeholder), `POST /conversations/:id/messages` 하드게이트에 peer-veto·
그룹차단 다음 순서로 추가, `TwinDisabledByFlood` 대화방 플래그 + `POST
/conversations/:id/flood-reset` one-tap undo, 기존 `EscalationLog`/`InboxScreen`
재사용 + 대화 목록/채팅방 배너에 상태·재개 버튼 노출. **Track C 콘텐츠 갭 A/B/C 전체 완료.**
2026-08-03 2차 재분석으로 D(자율성 상대별 예외)/E(관계 메모 반영)/F(답장 마감 알림) 추가
발견 — C4(자율성 상대별 예외)도 **완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션
미배포) — `Contact.AutonomyLevel` 오버라이드 필드 + `resolveAutonomyLevel()`(연락처
오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조)로 `POST
/conversations/:id/messages`의 자율성 게이트 교체, `contacts_screen.dart`
`_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(답장 마감 알림)도 **완료(부분 검증)**
(2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포, 모바일 온디바이스 전용이라
`core-backend`/`ai-service` 변경 없음) — 순수 온디바이스 스누즈 저장(`ConversationSnoozes`
drift 테이블, 서버 전송 없음) + `chat_screen.dart`/`conversation_list_screen.dart` UI는
완전히 검증됨(단위 테스트). 로컬 알림은 `flutter_local_notifications`/`timezone`을 실제로
붙였지만(`flutter pub get` 성공, `zonedSchedule`/`cancel` 연동) **이 샌드박스에 실기기·
에뮬레이터가 없어 알림이 실제로 울리는지까지는 검증하지 못함** — 검증한 건 스케줄러 호출
인자가 맞는지(목 기반 단위 테스트)뿐이라 실기기 확인 전까지는 대화 목록/채팅방의 인앱
배지·배너가 실질적인 리마인드 경로. **이로써 2026-08-03 2차 갭 분석 배치(C4/C5/C6)는 모두
구현 완료 — 다만 C6의 "실제 OS 알림 발사" 부분은 Android 실기기 탭(N4-5~10)에서 처음
검증되는 항목으로 남는다.** Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능
- 실 FCM 기기 수신 · Android 실기기 탭(답장 마감 알림 실제 발사 확인 포함) · 사람 PoC 실행은 남음
### NEXT 순서
@ -175,9 +204,12 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
### Track C — 콘텐츠 갭 (PRD P0 대비 미구현, 2026-07-31 발견)
`roadmap.md` §2.7과 동일 항목. `PRD.md` §3.1 P0 표 대조 결과 발견. **우선순위: C1(단톡 따라잡기)
→ C2(관계별 페르소나) → C3(스팸 감지)** — 단톡 따라잡기는 v1 MVP 시나리오 2개 중 하나인데 현재
0% 구현이라 완성도 공백이 가장 큼.
`roadmap.md` §2.7과 동일 항목. `PRD.md` §3.1 P0 표 대조 결과 발견(2026-07-31), 2026-08-03
2차 재분석으로 D/E/F 추가. **우선순위: C1(단톡 따라잡기) → C2(관계별 페르소나) → C3(스팸 감지)
→ C4(자율성 상대별 예외) → C5(관계 메모 반영) → C6(답장 마감 알림)** — 단톡 따라잡기는 v1 MVP
시나리오 2개 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼; C4는 P0 명시 항목이지만
오버라이드 메커니즘 자체가 없어 C1~C3 다음; C5는 필드/UI가 이미 있고 프롬프트 주입만 빠져
비용이 가장 작음; C6은 P1이자 신규 구현 분량이 가장 커서 맨 마지막.
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
@ -186,12 +218,21 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 |
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | todo | `ai-service/app/generation.py` |
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) |
| **N4-C3b** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | **done** (2026-08-03) | `core-backend/models.go` `TwinSettings`(전역 기본값)·`Contact`(상대별 오버라이드) 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI |
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) |
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | **done** (2026-08-03) | `core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`, 안전 최소값 placeholder) + `main.go` `POST /conversations/:id/messages`의 peer-veto·그룹차단 다음, 에스컬레이션 이전 지점(우회 불가). `Conversation.TwinDisabledByFlood`로 대화방 단위 차단, `POST /conversations/:id/flood-reset`로 재개(거부권과 달리 되돌리기 가능) |
| **N4-C3b** | 도배 중단 시 사후 알림 | **done** (2026-08-03) | 기존 `EscalationLog`/`InboxScreen` 그대로 재사용(신규 알림 경로 없음). 대화 목록·채팅방 배너에 상태 표시 + "자동응대 재개" one-tap undo 버튼 추가 |
| **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` 필드 추가 | **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** | "이따 답장" 스누즈 저장 | **done** (2026-08-03) | 순수 온디바이스 — `mobile/lib/db/tables.dart` `ConversationSnoozes` drift 테이블(서버 전송 없음), `chat_screen.dart` 앱바 "이따 답장" → 빠른 선택(1시간 후/저녁에/내일) + 해제, 실제 답장 전송 시 자동 해제 |
| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 |
| **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) |
### FCM — [`fcm-setup.md`](./fcm-setup.md)
@ -250,9 +291,17 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음:
**Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)**
N4-4 스모크 → Android UI QA (N4-5~10)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03),
~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done**
(2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가
발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~
**done** (2026-08-03), ~~Track C6 답장 마감 알림~~ **done(부분 검증)** (2026-08-03) —
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. 남은 것: **Master FCM
시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10, 답장 마감 알림 실기기 확인
포함), Track C 프로덕션 재배포(C2~C6는 아직 GitHub `main`에만 있음)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -127,11 +127,16 @@
지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지
않고 명시적으로 비워둠)
**2.7 콘텐츠 갭 — PRD 3.1 P0 대비 미구현 기능** (2026-07-31 발견)
**2.7 콘텐츠 갭 — PRD 3.1 P0 대비 미구현 기능** (2026-07-31 발견, 2026-08-03 2차 재분석으로
2.7-D~F 추가)
`PRD.md` §3.1 P0 표와 실제 코드를 대조해 발견. **우선순위: 2.7-A → 2.7-B → 2.7-C** (이유:
단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼;
페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음).
`PRD.md` §3.1 P0 표와 실제 코드를 대조해 발견. **우선순위: 2.7-A → 2.7-B → 2.7-C → 2.7-D →
2.7-E → 2.7-F** (이유: 단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라
완성도 공백이 가장 큼; 페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소
버전"이라 상대적으로 작음; 자율성 상대별 예외는 P0에 명시된 항목인데 관계별 페르소나와 달리
오버라이드 메커니즘 자체가 없어서 우선순위 4; 관계 메모는 필드/UI는 이미 있고 프롬프트 주입
한 줄만 빠진 상태라 비용이 가장 작음; 답장 마감 알림은 P1이자 셋 중 가장 새로 만들어야 할
분량이 커서 맨 마지막).
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
@ -152,18 +157,107 @@
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재
`TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음)
- [ ] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `core-backend/models.go`
- [ ] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart` (현재는 말투 샘플 4개뿐)
- [ ] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart`, 자율성 레벨의 상대별 예외와 동일 패턴
- [ ] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py` 시스템 프롬프트에 주입
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종 — **완료** 2026-08-03)
- [x] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `TwinSettings`
추가, 신규 유저 기본값은 안전 우선으로 `formal`. `Contact`에도 상대별 오버라이드 필드 추가
- [x] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역
기본값 선택(`SegmentedButton`)
- [x] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart` 연락처 추가/수정 다이얼로그에
`_RelationshipTierPicker`(기본값 사용/가까운 사이/공식적인 사이 3-way 칩), 자율성 화면에도
전역 기본값 변경 UI 추가
- [x] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py`
`RELATIONSHIP_TIER_INSTRUCTIONS` + `system_prompt_for_tier()`. `core-backend`
`POST /conversations/:id/draft` 호출마다 `resolveRelationshipTier()`로 해석(연락처 오버라이드
→ 전역 기본값 → `formal`) 해서 `ai-service`로 전달. 그룹 대화는 상대가 여럿이라 항상 전역
기본값만 사용. 이 엔드포인트가 원래 인증을 요구하지 않던 걸 깨지 않도록 `currentUser(..., false)`
선택적 인증 처리 — 토큰 없는 기존 호출도 그대로 동작(티어 해석만 스킵)
**2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로
명시 — 현재 코드 전체에 관련 로직 0건)
- [ ] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — 에스컬레이션 하드게이트와
동일 위치(우회 불가 지점)에 추가. `ai-service/app/escalation_filter.py` 또는 신규 규칙
- [ ] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 재사용
명시 — **완료** 2026-08-03)
- [x] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — `core-backend/flood_detect.go`
(`floodMessageThreshold = 5`건 / `floodWindow = 2분`, **PoC 검증값이 아니라 안전 최소값으로
명시한 v1 placeholder** — §3 "PoC 결과가 있어야 정할 수 있는 것"과는 성격이 다른, 반드시
있어야 하는 기술적 안전장치라 임시값으로 우선 구현). `main.go`의 `POST
/conversations/:id/messages`에서 peer-veto → 그룹 대화 차단 다음, 에스컬레이션 하드게이트
이전 지점에 추가 — 트윈 발송 시도 전체가 지나가는 동일 우회 불가 지점. 카운트는 대화방 내
`sender_id != 소유자`인 메시지(=상대가 보낸 것, 트윈 자동발송·소유자 본인 발송 모두 제외)만
집계. `Conversation.TwinDisabledByFlood`로 대화방 단위 영구 차단(거부권과 동일 저장 패턴) —
단, 거부권과 달리 **사람의 선택이 아닌 시스템의 자동 조치**라 되돌릴 수 있어야 해서(AGENTS.md
"every automatic action needs post-hoc notification + one-tap undo") `POST
/conversations/:id/flood-reset`로 재개 가능(거부권은 v1에서 되돌리기 API 없음, 의도적으로 다름)
- [x] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 그대로 재사용(신규 알림
경로 없음). 대화 목록(`conversation_list_screen.dart`)에도 `twin_disabled_by_flood` 배지 추가
(거부권 배지와 같은 자리, 다른 아이콘). 재개(one-tap undo)는 `chat_screen.dart`의 배너에
"자동응대 재개" 버튼으로 노출 — 채팅방을 열면(목록에서 넘어온 초기 상태) 또는 발송 시도가
다시 차단되면 즉시 뜬다
**2.7-D 자율성 상대별 예외** (`PRD.md` §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외
설정", §4 엣지케이스 "사용자가 여러 상대에게 다른 자율성 레벨을 원함" — P0인데 현재 전역
`TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견 —
**완료** 2026-08-03)
- [x] `Contact``AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와
동일 패턴, nil = 전역 기본값 사용) — `core-backend/models.go`
- [x] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`
와 동일 구조) — `core-backend/autonomy_resolve.go``resolveAutonomyLevel()`. 메시지
발송 게이트(`main.go` `POST /conversations/:id/messages`)가 `db.Where("user_id =
?", req.SenderID).First(&settings)`로 전역값만 읽던 걸 이 해석 함수 호출 한 줄로 교체 —
peer-veto·그룹 차단·도배 감지·에스컬레이션 하드게이트는 순서 그대로 유지, "level" 계산
방식만 바뀜. 실패 시 폴백은 `L1`/`L2`가 아니라 `L0`(이 코드베이스 전반의 안전 우선 기본값과
동일한 이유 — 알 수 없으면 항상 초안만 만들고 사람이 직접 보냄)
- [x] 연락처 추가/수정 다이얼로그에 자율성 레벨 오버라이드 UI(`contacts_screen.dart`,
`_RelationshipTierPicker`와 나란히 `_AutonomyLevelPicker`(기본값 사용/L0/L1/L2 4-way 칩) 추가,
연락처 목록 서브타이틀에도 오버라이드 표시)
- [x] 그룹 대화는 기존과 동일하게 항상 전역 L0 취급 유지(단톡 따라잡기 안전 불변식 변경 없음) —
`resolveAutonomyLevel()``RelationshipTier`와 동일하게 그룹 대화면 연락처 오버라이드를
건너뛰고 전역 기본값만 사용하도록 구현, 그룹 대화는 애초에 `main.go`의 무조건 차단이 이
해석 함수 호출보다 먼저 걸려서 두 안전장치가 이중으로 겹침
**2.7-E 관계 메모 실제 반영** (`PRD.md` §3.2 P1, `Contact.RelationshipNote` 필드·CRUD는 이미
있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는
스텁, 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 발견 — **완료(부분 검증)** 2026-08-03, 아래 참고)
- [x] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장 — 순수 온디바이스
(`mobile/lib/db/tables.dart`의 `ConversationSnoozes` drift 테이블, 서버에는 전혀
전달되지 않음). `chat_screen.dart` 앱바에 "이따 답장" 아이콘 → `SimpleDialog`
빠른 선택(`1시간 후`/`저녁에`/`내일`, `mobile/lib/services/snooze_service.dart`
`SnoozeQuickPick`) + 스누즈 해제. 기본값은 빠른 선택 없이도 쓸 수 있게 상수
`kDefaultSnoozeDuration`(2시간)로 노출은 해 두었지만 UI 자체는 빠른 선택 3개만
제공(추가 커스텀 시각 입력은 P1 범위상 생략). 사람이 실제로 답장을 보내면(휴먼
전송이든 트윈 승인 전송이든) 자동으로 스누즈 해제(`_clearSnoozeQuiet()`)
- [x] 리마인드 도달 시 로컬 알림 — **`flutter_local_notifications`(+`timezone`) 실제
연동, 단 실기기/에뮬레이터로 발사·탭 동작까지 검증하지는 못함.** 이 샌드박스는
Flutter 웹 빌드와 Playwright 브라우저 자동화만 가능하고 실제 Android/iOS
기기·에뮬레이터가 없어 `zonedSchedule()`이 만든 예약이 실제로 울리는지는 확인할
수 없다 — 검증한 것은 `mobile/lib/services/snooze_controller.dart`가 스케줄러
인터페이스(`SnoozeNotificationScheduler`)를 목(mock)으로 바꿔 넣었을 때 올바른
id·시각·페이로드로 호출되는가뿐(`test/snooze_controller_test.dart`). 대신 진짜로
검증 가능한 **인앱 대체 노출**을 항상 함께 켜 둔다 — 앱을 열 때(대화 목록 로드,
채팅방 진입) 마감이 지난 스누즈는 배지/배너로 반드시 보인다(아래 항목). 웹 빌드는
플러그인 자체가 웹을 지원하지 않아 `snooze_notification_service_web.dart`
no-op 스텁으로 대체(웹은 프리뷰 서피스일 뿐 릴리스 타깃이 아님)
- [x] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI —
채팅방은 앱바 아이콘 상태(`Icons.alarm_on`) + 마감 지난 스누즈 배너("스누즈
해제" 버튼), 대화 목록은 서브타이틀 아래 "답장 마감 (탭하여 해제)" 배지(탭하면
채팅방을 열지 않고도 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue(now,
snoozedUntil)`로 분리해 고정 시각으로 단위 테스트(`test/snooze_service_test.dart`)
**스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저"
테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건
@ -199,8 +293,9 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
§3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API
6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
- 6-1. [ ] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별
페르소나 → C 스팸 감지**, 2026-07-31 발견). §3 사람 PoC보다 먼저 끝내야 함 — PRD가 요구하는
- 6-1. [x] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별
페르소나 → C 스팸 감지**, 2026-07-31 발견 — A/B/C 모두 완료 2026-08-03). §3 사람 PoC보다
먼저 끝내야 함 — PRD가 요구하는
v1 P0 범위이므로 §4 순서상 D(사람 PoC) 앞
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)

View File

@ -13,9 +13,9 @@ import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
part 'app_database_native.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
@DriftDatabase(tables: [ToneSamples, LocalKv, ConversationSnoozes])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e, {this.encrypted = false});
@ -23,7 +23,18 @@ class AppDatabase extends _$AppDatabase {
final bool encrypted;
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// v1 -> v2: roadmap.md §2.7-F .
if (from < 2) {
await m.createTable(conversationSnoozes);
}
},
);
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
@ -76,6 +87,42 @@ class AppDatabase extends _$AppDatabase {
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
/// (roadmap.md §2.7-F) "이따 답장" [conversationId]
/// . .
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
await into(conversationSnoozes).insertOnConflictUpdate(
ConversationSnoozesCompanion.insert(
conversationId: conversationId.toString(),
snoozedUntilMs: until.millisecondsSinceEpoch,
),
);
}
Future<DateTime?> getSnoozedUntil(int conversationId) async {
final row = await (select(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.getSingleOrNull();
if (row == null) return null;
// isUtc: true millisecondsSinceEpoch는
// , UTC로 DateTime.== ( ) .
return DateTime.fromMillisecondsSinceEpoch(row.snoozedUntilMs, isUtc: true);
}
Future<void> clearSnooze(int conversationId) async {
await (delete(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.go();
}
/// (`conversation_list_screen.dart`) .
Future<Map<int, DateTime>> loadAllSnoozes() async {
final rows = await select(conversationSnoozes).get();
return {
for (final r in rows)
int.parse(r.conversationId): DateTime.fromMillisecondsSinceEpoch(r.snoozedUntilMs, isUtc: true),
};
}
}
const _kDbPassphrase = 'db_passphrase_v1';

View File

@ -532,16 +532,256 @@ class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
}
}
class $ConversationSnoozesTable extends ConversationSnoozes
with TableInfo<$ConversationSnoozesTable, ConversationSnooze> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ConversationSnoozesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _conversationIdMeta = const VerificationMeta(
'conversationId',
);
@override
late final GeneratedColumn<String> conversationId = GeneratedColumn<String>(
'conversation_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _snoozedUntilMsMeta = const VerificationMeta(
'snoozedUntilMs',
);
@override
late final GeneratedColumn<int> snoozedUntilMs = GeneratedColumn<int>(
'snoozed_until_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [conversationId, snoozedUntilMs];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'conversation_snoozes';
@override
VerificationContext validateIntegrity(
Insertable<ConversationSnooze> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('conversation_id')) {
context.handle(
_conversationIdMeta,
conversationId.isAcceptableOrUnknown(
data['conversation_id']!,
_conversationIdMeta,
),
);
} else if (isInserting) {
context.missing(_conversationIdMeta);
}
if (data.containsKey('snoozed_until_ms')) {
context.handle(
_snoozedUntilMsMeta,
snoozedUntilMs.isAcceptableOrUnknown(
data['snoozed_until_ms']!,
_snoozedUntilMsMeta,
),
);
} else if (isInserting) {
context.missing(_snoozedUntilMsMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {conversationId};
@override
ConversationSnooze map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ConversationSnooze(
conversationId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}conversation_id'],
)!,
snoozedUntilMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}snoozed_until_ms'],
)!,
);
}
@override
$ConversationSnoozesTable createAlias(String alias) {
return $ConversationSnoozesTable(attachedDatabase, alias);
}
}
class ConversationSnooze extends DataClass
implements Insertable<ConversationSnooze> {
/// `Conversation.id` (drift
/// text ).
final String conversationId;
final int snoozedUntilMs;
const ConversationSnooze({
required this.conversationId,
required this.snoozedUntilMs,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['conversation_id'] = Variable<String>(conversationId);
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs);
return map;
}
ConversationSnoozesCompanion toCompanion(bool nullToAbsent) {
return ConversationSnoozesCompanion(
conversationId: Value(conversationId),
snoozedUntilMs: Value(snoozedUntilMs),
);
}
factory ConversationSnooze.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ConversationSnooze(
conversationId: serializer.fromJson<String>(json['conversationId']),
snoozedUntilMs: serializer.fromJson<int>(json['snoozedUntilMs']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'conversationId': serializer.toJson<String>(conversationId),
'snoozedUntilMs': serializer.toJson<int>(snoozedUntilMs),
};
}
ConversationSnooze copyWith({String? conversationId, int? snoozedUntilMs}) =>
ConversationSnooze(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
);
ConversationSnooze copyWithCompanion(ConversationSnoozesCompanion data) {
return ConversationSnooze(
conversationId: data.conversationId.present
? data.conversationId.value
: this.conversationId,
snoozedUntilMs: data.snoozedUntilMs.present
? data.snoozedUntilMs.value
: this.snoozedUntilMs,
);
}
@override
String toString() {
return (StringBuffer('ConversationSnooze(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(conversationId, snoozedUntilMs);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ConversationSnooze &&
other.conversationId == this.conversationId &&
other.snoozedUntilMs == this.snoozedUntilMs);
}
class ConversationSnoozesCompanion extends UpdateCompanion<ConversationSnooze> {
final Value<String> conversationId;
final Value<int> snoozedUntilMs;
final Value<int> rowid;
const ConversationSnoozesCompanion({
this.conversationId = const Value.absent(),
this.snoozedUntilMs = const Value.absent(),
this.rowid = const Value.absent(),
});
ConversationSnoozesCompanion.insert({
required String conversationId,
required int snoozedUntilMs,
this.rowid = const Value.absent(),
}) : conversationId = Value(conversationId),
snoozedUntilMs = Value(snoozedUntilMs);
static Insertable<ConversationSnooze> custom({
Expression<String>? conversationId,
Expression<int>? snoozedUntilMs,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (conversationId != null) 'conversation_id': conversationId,
if (snoozedUntilMs != null) 'snoozed_until_ms': snoozedUntilMs,
if (rowid != null) 'rowid': rowid,
});
}
ConversationSnoozesCompanion copyWith({
Value<String>? conversationId,
Value<int>? snoozedUntilMs,
Value<int>? rowid,
}) {
return ConversationSnoozesCompanion(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (conversationId.present) {
map['conversation_id'] = Variable<String>(conversationId.value);
}
if (snoozedUntilMs.present) {
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ConversationSnoozesCompanion(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
late final $LocalKvTable localKv = $LocalKvTable(this);
late final $ConversationSnoozesTable conversationSnoozes =
$ConversationSnoozesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
List<DatabaseSchemaEntity> get allSchemaEntities => [
toneSamples,
localKv,
conversationSnoozes,
];
}
typedef $$ToneSamplesTableCreateCompanionBuilder =
@ -862,6 +1102,169 @@ typedef $$LocalKvTableProcessedTableManager =
LocalKvData,
PrefetchHooks Function()
>;
typedef $$ConversationSnoozesTableCreateCompanionBuilder =
ConversationSnoozesCompanion Function({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid,
});
typedef $$ConversationSnoozesTableUpdateCompanionBuilder =
ConversationSnoozesCompanion Function({
Value<String> conversationId,
Value<int> snoozedUntilMs,
Value<int> rowid,
});
class $$ConversationSnoozesTableFilterComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnFilters(column),
);
}
class $$ConversationSnoozesTableOrderingComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnOrderings(column),
);
}
class $$ConversationSnoozesTableAnnotationComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => column,
);
GeneratedColumn<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => column,
);
}
class $$ConversationSnoozesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
> {
$$ConversationSnoozesTableTableManager(
_$AppDatabase db,
$ConversationSnoozesTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ConversationSnoozesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ConversationSnoozesTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
$$ConversationSnoozesTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
Value<String> conversationId = const Value.absent(),
Value<int> snoozedUntilMs = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
createCompanionCallback:
({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion.insert(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$ConversationSnoozesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@ -870,4 +1273,6 @@ class $AppDatabaseManager {
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
$$LocalKvTableTableManager get localKv =>
$$LocalKvTableTableManager(_db, _db.localKv);
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
}

View File

@ -11,6 +11,7 @@ class AppDatabase {
final Map<String, String> _kv = {};
List<String> _toneSamples = [];
final Map<int, DateTime> _snoozes = {};
factory AppDatabase.memory() => AppDatabase(encrypted: false);
@ -38,5 +39,19 @@ class AppDatabase {
await setKv(key, value ? '1' : '0');
}
// roadmap.md §2.7-F native stub과
// (in-memory, encrypted persistence web은 ).
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
_snoozes[conversationId] = until;
}
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
Future<void> clearSnooze(int conversationId) async {
_snoozes.remove(conversationId);
}
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
Future<void> close() async {}
}

View File

@ -19,3 +19,16 @@ class LocalKv extends Table {
@override
Set<Column<Object>> get primaryKey => {key};
}
/// (roadmap.md §2.7-F) "이따 답장" .
/// ( ,
/// AGENTS.md "Tone/style learning: on-device first" ).
class ConversationSnoozes extends Table {
/// `Conversation.id` (drift
/// text ).
TextColumn get conversationId => text()();
IntColumn get snoozedUntilMs => integer()();
@override
Set<Column<Object>> get primaryKey => {conversationId};
}

View File

@ -4,6 +4,26 @@ enum SenderMode { human, twin }
// ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 }
extension AutonomyLevelLabel on AutonomyLevel {
/// Short label reused verbatim from autonomy_settings_screen.dart's
/// segmented-button labels ("L0"/"L1"/"L2") so per-contact chips
/// (roadmap.md §2.7-D) don't invent new copy.
String get label => name;
}
/// (roadmap.md §2.7-B, PRD.md §2.1-/§3.1) minimum 2 tiers.
enum RelationshipTier {
close,
formal;
static RelationshipTier fromJson(String? raw) => RelationshipTier.values.firstWhere(
(e) => e.name == raw,
orElse: () => RelationshipTier.formal,
);
String get label => this == RelationshipTier.close ? '가까운 사이' : '공식적인 사이';
}
class User {
User({required this.id, required this.displayName, required this.inviteCode});
@ -19,9 +39,10 @@ class User {
}
class TwinSettings {
TwinSettings({required this.autonomyLevel});
TwinSettings({required this.autonomyLevel, required this.relationshipTier});
final AutonomyLevel autonomyLevel;
final RelationshipTier relationshipTier;
factory TwinSettings.fromJson(Map<String, dynamic> json) {
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
@ -30,6 +51,7 @@ class TwinSettings {
(e) => e.name == raw,
orElse: () => AutonomyLevel.L0,
),
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
);
}
}
@ -40,6 +62,7 @@ class ConversationSummary {
required this.isGroup,
required this.userIds,
required this.twinDisabledByPeer,
this.twinDisabledByFlood = false,
this.unreadCount = 0,
this.createdAt,
});
@ -48,6 +71,9 @@ class ConversationSummary {
final bool isGroup;
final List<int> userIds;
final bool twinDisabledByPeer;
// roadmap.md §2.7-C / : ( )
// twinDisabledByPeer와 (POST /conversations/:id/flood-reset).
final bool twinDisabledByFlood;
final int unreadCount;
final DateTime? createdAt;
@ -58,6 +84,7 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
twinDisabledByFlood: json['twin_disabled_by_flood'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
);
@ -78,18 +105,36 @@ class Contact {
required this.displayName,
this.contactUserId,
this.relationshipNote = '',
this.relationshipTier,
this.autonomyLevel,
});
final int id;
final String displayName;
final int? contactUserId;
final String relationshipNote;
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). Null means "use the global default".
final RelationshipTier? relationshipTier;
/// Per-contact override of the global autonomy level (roadmap.md §2.7-D,
/// PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Null means "use the
/// global default".
final AutonomyLevel? autonomyLevel;
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? '',
contactUserId: json['contact_user_id'] as int?,
relationshipNote: json['relationship_note'] as String? ?? '',
relationshipTier: json['relationship_tier'] == null
? null
: RelationshipTier.fromJson(json['relationship_tier'] as String?),
autonomyLevel: json['autonomy_level'] == null
? null
: AutonomyLevel.values.firstWhere(
(e) => e.name == json['autonomy_level'] as String?,
orElse: () => AutonomyLevel.L0,
),
);
}

View File

@ -129,6 +129,21 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
),
),
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),
const SizedBox(height: 12),
Row(

View File

@ -5,17 +5,28 @@ import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../services/snooze_service.dart';
import '../services/ws_client.dart';
import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.conversationId, this.title, this.isGroup = false});
const ChatScreen({
super.key,
required this.conversationId,
this.title,
this.isGroup = false,
this.twinDisabledByFlood = false,
});
final int conversationId;
final String? title;
final bool isGroup;
// roadmap.md §2.7-C :
// , ( ) +
// .
final bool twinDisabledByFlood;
@override
State<ChatScreen> createState() => _ChatScreenState();
@ -32,15 +43,102 @@ class _ChatScreenState extends State<ChatScreen> {
DraftResult? _pendingDraft;
bool _busy = false;
bool _loadingHistory = true;
late bool _floodBlocked;
late final ApiClient _api;
// (roadmap.md §2.7-F) "이따 답장" .
// DB에서 .
DateTime? _snoozedUntil;
@override
void initState() {
super.initState();
_api = context.read<SessionState>().api;
_floodBlocked = widget.twinDisabledByFlood;
if (_floodBlocked) {
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
}
_socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent);
_loadHistory();
_loadSnooze();
}
Future<void> _loadSnooze() async {
final controller = context.read<SessionState>().snoozeController;
if (controller == null) return;
final until = await controller.loadSnoozedUntil(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = until);
}
bool get _snoozePastDue => isSnoozePastDue(DateTime.now(), _snoozedUntil);
String _formatSnoozeUntil(DateTime dt) =>
'${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
/// "이따 답장" (roadmap.md §2.7-F).
/// .
Future<void> _openSnoozeMenu() async {
final now = DateTime.now();
final choice = await showDialog<Object>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('이따 답장'),
children: [
for (final pick in SnoozeQuickPick.values)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, pick),
child: Text(pick.label),
),
if (_snoozedUntil != null)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'clear'),
child: Text('스누즈 해제', style: TextStyle(color: Theme.of(ctx).colorScheme.error)),
),
],
),
);
if (choice == null || !mounted) return;
if (choice == 'clear') {
await _clearSnooze();
return;
}
if (choice is SnoozeQuickPick) {
await _applySnooze(resolveSnoozeQuickPick(choice, now));
}
}
Future<void> _applySnooze(DateTime until) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) {
await controller.applySnooze(
conversationId: widget.conversationId,
until: until,
title: '답장 마감',
body: '${widget.title ?? "대화방 #${widget.conversationId}"}에 답장할 시간이에요',
);
}
if (!mounted) return;
setState(() {
_snoozedUntil = until;
_banner = '이따 답장: ${_formatSnoozeUntil(until)}에 리마인드합니다 (본인에게만 표시).';
});
}
/// "스누즈 해제" .
Future<void> _clearSnooze() async {
await _clearSnoozeQuiet();
if (!mounted) return;
setState(() => _banner = '스누즈를 해제했습니다.');
}
/// ( ).
Future<void> _clearSnoozeQuiet() async {
if (_snoozedUntil == null) return;
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = null);
}
Future<void> _loadHistory() async {
@ -149,6 +247,8 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() => _messages.add(msg));
}
_scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally {
@ -230,13 +330,42 @@ class _ChatScreenState extends State<ChatScreen> {
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
});
_scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) {
setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}');
setState(() {
_banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}';
// roadmap.md §2.7-C:
// ,
// .
if (e.body.contains('도배')) _floodBlocked = true;
});
} finally {
setState(() => _busy = false);
}
}
/// (roadmap.md §2.7-C,
/// AGENTS.md "every automatic action needs post-hoc notification +
/// one-tap undo"). 거부권(veto)과 달리 이 중단은 시스템이 자동으로 취한
/// .
Future<void> _resumeFlood() async {
final session = context.read<SessionState>();
setState(() => _busy = true);
try {
await session.api.resetFlood(widget.conversationId);
if (!mounted) return;
setState(() {
_floodBlocked = false;
_banner = '와카뷰 자동응대를 다시 켰습니다.';
});
} on ApiException catch (e) {
setState(() => _banner = '재개 실패 (${e.statusCode})');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _rejectDraft() {
setState(() {
_pendingDraft = null;
@ -449,6 +578,13 @@ class _ChatScreenState extends State<ChatScreen> {
onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined),
),
IconButton(
// (roadmap.md §2.7-F) "이따 답장" .
// .
tooltip: _snoozedUntil != null ? '이따 답장 (${_formatSnoozeUntil(_snoozedUntil!)})' : '이따 답장',
onPressed: _busy ? null : _openSnoozeMenu,
icon: Icon(_snoozedUntil != null ? Icons.alarm_on : Icons.alarm_add_outlined),
),
IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto,
@ -463,9 +599,21 @@ class _ChatScreenState extends State<ChatScreen> {
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
content: Text(_banner!),
actions: [
// one-tap undo (AGENTS.md )
// .
if (_floodBlocked)
TextButton(onPressed: _busy ? null : _resumeFlood, child: const Text('자동응대 재개')),
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
],
),
if (_snoozePastDue)
MaterialBanner(
leading: Icon(Icons.alarm, color: theme.colorScheme.tertiary),
content: Text('답장 마감 시간이 지났습니다 (${_formatSnoozeUntil(_snoozedUntil!)}).'),
actions: [
TextButton(onPressed: _busy ? null : _clearSnooze, child: const Text('스누즈 해제')),
],
),
Expanded(
child: _loadingHistory
? const Center(child: CircularProgressIndicator())

View File

@ -48,9 +48,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
final noteCtrl = TextEditingController();
final session = context.read<SessionState>();
final myId = session.user?.id;
RelationshipTier? tierOverride;
AutonomyLevel? autonomyOverride;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('연락처 추가'),
content: SingleChildScrollView(
child: Column(
@ -82,6 +85,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
const SizedBox(height: 12),
Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_AutonomyLevelPicker(
value: autonomyOverride,
onChanged: (l) => setDialogState(() => autonomyOverride = l),
),
],
),
),
@ -90,6 +107,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
],
),
),
);
if (ok != true || !mounted) return;
final name = nameCtrl.text.trim();
@ -109,6 +127,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name,
contactUserId: peer,
relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
);
setState(() {
_contacts = [..._contacts, created];
@ -149,9 +169,14 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
final noteCtrl = TextEditingController(text: contact.relationshipNote);
final session = context.read<SessionState>();
// PATCH는 (=
// ) null로 .
RelationshipTier? tierOverride = contact.relationshipTier;
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
content: SingleChildScrollView(
child: Column(
@ -184,6 +209,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
const SizedBox(height: 12),
Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_AutonomyLevelPicker(
value: autonomyOverride,
onChanged: (l) => setDialogState(() => autonomyOverride = l),
),
],
),
),
@ -192,6 +231,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
],
),
),
);
if (ok != true || !mounted || session.user == null) return;
final name = nameCtrl.text.trim();
@ -212,6 +252,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name,
contactUserId: peer,
relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
);
setState(() {
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
@ -333,6 +375,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
: [
'사용자 #${c.contactUserId}',
if (c.relationshipTier != null) c.relationshipTier!.label,
if (c.autonomyLevel != null) c.autonomyLevel!.label,
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
@ -381,3 +425,62 @@ 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),
),
],
);
}
}
/// Per-contact override of the global autonomy level (roadmap.md §2.7-D).
/// `value: null` means "use the global default set in 자율성 설정".
/// Mirrors _RelationshipTierPicker's structure exactly.
class _AutonomyLevelPicker extends StatelessWidget {
const _AutonomyLevelPicker({required this.value, required this.onChanged});
final AutonomyLevel? value;
final ValueChanged<AutonomyLevel?> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('기본값 사용'),
selected: value == null,
onSelected: (_) => onChanged(null),
),
for (final level in AutonomyLevel.values)
ChoiceChip(
label: Text(level.label),
selected: value == level,
onSelected: (_) => onChanged(level),
),
],
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../services/snooze_service.dart';
import '../state/session_state.dart';
import '../widgets/gradient_text.dart';
import '../widgets/my_user_id_chip.dart';
@ -22,6 +23,9 @@ class ConversationListScreen extends StatefulWidget {
class _ConversationListScreenState extends State<ConversationListScreen> {
List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {};
// (roadmap.md §2.7-F) id별 "이따 답장" .
// (ConversationSummary) DB에서 .
Map<int, DateTime> _snoozes = {};
bool _loading = true;
String? _error;
@ -53,9 +57,11 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
// Names are optional enrichment.
}
}
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
setState(() {
_rooms = list;
_peerNames = names;
_snoozes = snoozes;
});
} on ApiException catch (e) {
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
@ -64,6 +70,15 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
}
}
/// (roadmap.md §2.7-F
/// "마감 지남" ).
Future<void> _clearSnoozeFromList(int conversationId) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(conversationId);
if (!mounted) return;
setState(() => _snoozes.remove(conversationId));
}
String _titleFor(ConversationSummary room, int? me) {
if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList();
@ -77,6 +92,9 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
String _subtitleFor(ConversationSummary room, int? me) {
if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함';
// roadmap.md §2.7-C (InboxScreen)
// .
if (room.twinDisabledByFlood) return '도배 감지로 자동응대 일시중단 (사후 알림에서 재개)';
final peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList();
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
return '$peerPart · 방 #${room.id}';
@ -332,9 +350,19 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
),
),
for (final room in _rooms)
Material(
color: Colors.transparent,
child: InkWell(
Builder(
builder: (context) {
final snoozedUntil = _snoozes[room.id];
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
return _ConversationRow(
room: room,
me: me,
title: _titleFor(room, me),
subtitle: _subtitleFor(room, me),
avatarColor: _avatarColor(context, room.id),
onAvatarColor: _onAvatarColor(context, room.id),
snoozePastDue: snoozePastDue,
onClearSnooze: () => _clearSnoozeFromList(room.id),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
@ -342,22 +370,65 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
conversationId: room.id,
title: _titleFor(room, me),
isGroup: room.isGroup,
twinDisabledByFlood: room.twinDisabledByFlood,
),
),
);
await _load();
},
);
},
),
const SizedBox(height: 72),
],
),
),
);
}
}
class _ConversationRow extends StatelessWidget {
const _ConversationRow({
required this.room,
required this.me,
required this.title,
required this.subtitle,
required this.avatarColor,
required this.onAvatarColor,
required this.snoozePastDue,
required this.onClearSnooze,
required this.onTap,
});
final ConversationSummary room;
final int? me;
final String title;
final String subtitle;
final Color avatarColor;
final Color onAvatarColor;
final bool snoozePastDue;
final VoidCallback onClearSnooze;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: _avatarColor(context, room.id),
backgroundColor: avatarColor,
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
size: 20,
color: _onAvatarColor(context, room.id),
color: onAvatarColor,
),
),
const SizedBox(width: 12),
@ -366,30 +437,52 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_titleFor(room, me),
title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Row(
children: [
if (room.twinDisabledByPeer) ...[
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
if (blockedColor) ...[
Icon(
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
size: 13,
color: theme.colorScheme.error,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
_subtitleFor(room, me),
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
// (roadmap.md §2.7-F)
// . .
if (snoozePastDue) ...[
const SizedBox(height: 4),
InkWell(
borderRadius: BorderRadius.circular(4),
onTap: onClearSnooze,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.alarm, size: 13, color: theme.colorScheme.tertiary),
const SizedBox(width: 4),
Text(
'답장 마감 (탭하여 해제)',
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.tertiary),
),
],
),
),
],
],
),
),
@ -415,11 +508,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
),
),
),
),
const SizedBox(height: 72),
],
),
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../state/session_state.dart';
import '../widgets/primary_gradient_button.dart';
@ -16,16 +17,19 @@ class OnboardingToneScreen extends StatefulWidget {
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
final _samples = List.generate(4, (_) => TextEditingController());
var _seeded = false;
RelationshipTier _tier = RelationshipTier.formal;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_seeded) return;
_seeded = true;
final existing = context.read<SessionState>().styleExamples;
final session = context.read<SessionState>();
final existing = session.styleExamples;
for (var i = 0; i < existing.length && i < _samples.length; i++) {
_samples[i].text = existing[i];
}
_tier = session.relationshipTier;
}
@override
@ -42,6 +46,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
_samples.map((c) => c.text).toList(),
markDone: markDone,
);
await session.setRelationshipTier(_tier);
}
@override
@ -84,7 +89,22 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
),
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(
label: '이 말투로 시작',
onPressed: () => _save(markDone: true),

View File

@ -118,11 +118,15 @@ class ApiClient {
required String displayName,
int? contactUserId,
String relationshipNote = '',
RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async {
final json = await _json('POST', '/users/$userId/contacts', body: {
'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
});
return Contact.fromJson(json);
}
@ -133,11 +137,15 @@ class ApiClient {
required String displayName,
int? contactUserId,
String relationshipNote = '',
RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async {
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
});
return Contact.fromJson(json);
}
@ -185,6 +193,13 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto');
}
/// (roadmap.md §2.7-C, AGENTS.md
/// "every automatic action needs post-hoc notification + one-tap undo").
/// (veto) .
Future<void> resetFlood(int conversationId) async {
await _json('POST', '/conversations/$conversationId/flood-reset');
}
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {
@ -201,9 +216,14 @@ class ApiClient {
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: {
'autonomy_level': level.name,
'autonomy_level': autonomyLevel.name,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
});
return TwinSettings.fromJson(json);
}

View File

@ -0,0 +1,39 @@
import '../db/app_database.dart';
import 'snooze_notification_service.dart';
/// (roadmap.md §2.7-F) DB( )
/// (OS ) . `chat_screen.dart`/
/// `conversation_list_screen.dart` .
///
/// AGENTS.md "Tone/style learning: on-device first"
/// , ·
/// . [scheduler] (mock) "DB에 정확히 반영되고
/// id// "까지 검증한다 — 실제 OS 알림이
/// ( / ).
class SnoozeController {
SnoozeController({required AppDatabase db, required SnoozeNotificationScheduler scheduler})
: _db = db,
_scheduler = scheduler;
final AppDatabase _db;
final SnoozeNotificationScheduler _scheduler;
Future<DateTime?> loadSnoozedUntil(int conversationId) => _db.getSnoozedUntil(conversationId);
Future<Map<int, DateTime>> loadAllSnoozes() => _db.loadAllSnoozes();
Future<void> applySnooze({
required int conversationId,
required DateTime until,
required String title,
required String body,
}) async {
await _db.setSnoozedUntil(conversationId, until);
await _scheduler.scheduleReminder(conversationId: conversationId, title: title, body: body, at: until);
}
Future<void> clearSnooze(int conversationId) async {
await _db.clearSnooze(conversationId);
await _scheduler.cancelReminder(conversationId);
}
}

View File

@ -0,0 +1,10 @@
/// (roadmap.md §2.7-F) OS
/// export로 (`../db/app_database.dart` ).
///
/// Flutter Web은 `flutter_local_notifications` ,
///
/// (`app_database_web.dart` ) no-op .
library;
export 'snooze_notification_service_native.dart'
if (dart.library.html) 'snooze_notification_service_web.dart';

View File

@ -0,0 +1,80 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// (roadmap.md §2.7-F, PRD.md §3.2 "내가 '이따 답장' 누르면 나에게만
/// ")의 스케줄러 인터페이스. 다른 사람에게는 절대 보이지 않는, 이 기기에만
/// AGENTS.md .
///
/// Android/iOS ·
/// / . `zonedSchedule()` /
/// `cancel()` id·· ,
/// (mock) (`test/snooze_notification_scheduler_test.dart`)
/// .
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
LocalSnoozeNotificationScheduler({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false;
static const _channelId = 'reply_snooze_reminders';
static const _channelName = '답장 마감 알림';
static const _channelDescription = '"이따 답장" 스누즈 마감 시각 알림 (본인 기기에만 표시, 상대에게는 보이지 않음)';
Future<void> _ensureInitialized() async {
if (_initialized) return;
tz_data.initializeTimeZones();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings();
await _plugin.initialize(
settings: const InitializationSettings(android: androidInit, iOS: iosInit),
);
_initialized = true;
}
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
await _ensureInitialized();
await _plugin.zonedSchedule(
id: conversationId,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(at, tz.local),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
),
// SCHEDULE_EXACT_ALARM
// (Doze ).
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
}
@override
Future<void> cancelReminder(int conversationId) async {
await _plugin.cancel(id: conversationId);
}
}

View File

@ -0,0 +1,27 @@
/// Web `flutter_local_notifications` ,
/// (`app_database_web.dart` ).
/// (`AppDatabase`) , OS no-op
/// / "마감 지남" · .
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {}
@override
Future<void> cancelReminder(int conversationId) async {}
}

View File

@ -0,0 +1,49 @@
/// (roadmap.md §2.7-F, PRD.md §3.2 P1 "내가 '이따 답장' 누르면 나에게만
/// ") 순수 로직. `DateTime.now()`에 의존하지 않고 `now`를 인자로 받는 함수들이라
/// .
library;
/// "이따 답장" ( ) .
const Duration kDefaultSnoozeDuration = Duration(hours: 2);
/// / .
enum SnoozeQuickPick { oneHour, thisEvening, tomorrowMorning }
extension SnoozeQuickPickLabel on SnoozeQuickPick {
String get label => switch (this) {
SnoozeQuickPick.oneHour => '1시간 후',
SnoozeQuickPick.thisEvening => '저녁에',
SnoozeQuickPick.tomorrowMorning => '내일',
};
}
/// [now] .
///
/// - 1 : 1
/// - : 19( 19)
/// - : 9
DateTime resolveSnoozeQuickPick(SnoozeQuickPick pick, DateTime now) {
switch (pick) {
case SnoozeQuickPick.oneHour:
return now.add(const Duration(hours: 1));
case SnoozeQuickPick.thisEvening:
final evening = DateTime(now.year, now.month, now.day, 19);
return now.isBefore(evening) ? evening : evening.add(const Duration(days: 1));
case SnoozeQuickPick.tomorrowMorning:
final tomorrow = now.add(const Duration(days: 1));
return DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 9);
}
}
/// [now] /
/// . `snoozedUntil` false.
bool isSnoozePastDue(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return !now.isBefore(snoozedUntil);
}
/// (= ).
bool isSnoozeActive(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return now.isBefore(snoozedUntil);
}

View File

@ -7,17 +7,27 @@ import '../db/app_database.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../services/push_token_service.dart';
import '../services/snooze_controller.dart';
import '../services/snooze_notification_service.dart';
class SessionState extends ChangeNotifier {
SessionState({ApiClient? api, AppDatabase? db})
SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler})
: _api = api ?? ApiClient(),
_db = db;
_db = db,
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
final ApiClient _api;
AppDatabase? _db;
final SnoozeNotificationScheduler _snoozeScheduler;
/// (roadmap.md §2.7-F) DB( )
/// . DB가 ( ) null.
SnoozeController? get snoozeController =>
_db == null ? null : SnoozeController(db: _db!, scheduler: _snoozeScheduler);
User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
RelationshipTier relationshipTier = RelationshipTier.formal;
String? error;
bool loading = false;
bool toneOnboardingDone = false;
@ -139,8 +149,9 @@ class SessionState extends ChangeNotifier {
Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return;
try {
final settings = await _api.patchTwinSettings(user!.id, level);
final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level);
autonomyLevel = settings.autonomyLevel;
relationshipTier = settings.relationshipTier;
notifyListeners();
} on ApiException catch (e) {
error = '자율성 변경 실패 (${e.statusCode})';
@ -148,6 +159,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
/// `install:` placeholder (server skips placeholders for delivery).
Future<void> _registerDeviceTokenBestEffort() async {

View File

@ -177,6 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
drift:
dependency: "direct main"
description:
@ -286,6 +294,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage:
dependency: "direct main"
description:
@ -552,6 +592,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -773,6 +821,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description:
@ -845,6 +901,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
@ -855,4 +919,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.29.0"
flutter: ">=3.32.0"

View File

@ -47,6 +47,8 @@ dependencies:
google_fonts: ^6.3.2
firebase_core: ^4.12.1
firebase_messaging: ^16.4.3
flutter_local_notifications: ^20.1.0
timezone: ^0.10.1
dev_dependencies:
flutter_test:

View File

@ -15,4 +15,34 @@ void main() {
await db.replaceToneSamples(['하나만']);
expect(await db.loadToneSamples(), ['하나만']);
});
// roadmap.md §2.7-F :
// tone samples와 (in-memory DB round-trip)
// .
test('conversation snooze round-trips in memory database', () async {
final db = AppDatabase.memory();
addTearDown(db.close);
expect(await db.getSnoozedUntil(42), isNull);
final until = DateTime.utc(2026, 8, 3, 20, 0);
await db.setSnoozedUntil(42, until);
expect(await db.getSnoozedUntil(42), until);
// (id 43) .
expect(await db.getSnoozedUntil(43), isNull);
// (insertOnConflictUpdate) .
final rescheduled = DateTime.utc(2026, 8, 4, 9, 0);
await db.setSnoozedUntil(42, rescheduled);
expect(await db.getSnoozedUntil(42), rescheduled);
await db.setSnoozedUntil(43, until);
final all = await db.loadAllSnoozes();
expect(all, {42: rescheduled, 43: until});
await db.clearSnooze(42);
expect(await db.getSnoozedUntil(42), isNull);
expect(await db.loadAllSnoozes(), {43: until});
});
}

View File

@ -13,9 +13,26 @@ void main() {
expect(c.id, 3);
expect(c.userIds, [1, 2]);
expect(c.twinDisabledByPeer, isTrue);
expect(c.twinDisabledByFlood, isFalse);
expect(c.titleFor(1), '대화 · 상대 #2');
});
// roadmap.md §2.7-C / : twin_disabled_by_flood parses
// independently of twin_disabled_by_peer and defaults to false when absent
// (covered by the test above).
test('ConversationSummary parses twin_disabled_by_flood', () {
final c = ConversationSummary.fromJson({
'id': 4,
'is_group': false,
'twin_disabled_by_peer': false,
'twin_disabled_by_flood': true,
'user_ids': [1, 2],
'created_at': '2026-07-30T00:00:00Z',
});
expect(c.twinDisabledByPeer, isFalse);
expect(c.twinDisabledByFlood, isTrue);
});
test('EscalationLogEntry and Contact parse', () {
final log = EscalationLogEntry.fromJson({
'id': 9,
@ -33,5 +50,28 @@ void main() {
'relationship_note': '대학',
});
expect(contact.contactUserId, 7);
expect(contact.relationshipTier, isNull);
expect(contact.autonomyLevel, isNull);
});
// roadmap.md §2.7-D: Contact.autonomyLevel is a nullable per-contact
// override of the global autonomy level, parsed only when present --
// mirrors relationshipTier's null-safe parsing above.
test('Contact parses autonomy_level override when present', () {
final withOverride = Contact.fromJson({
'id': 2,
'display_name': '친구2',
'contact_user_id': 8,
'autonomy_level': 'L2',
});
expect(withOverride.autonomyLevel, AutonomyLevel.L2);
expect(withOverride.autonomyLevel!.label, 'L2');
final withoutOverride = Contact.fromJson({
'id': 3,
'display_name': '친구3',
'contact_user_id': 9,
});
expect(withoutOverride.autonomyLevel, isNull);
});
}

View File

@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/db/app_database.dart';
import 'package:ykavu_mobile/services/snooze_controller.dart';
import 'package:ykavu_mobile/services/snooze_notification_service.dart';
/// `flutter_local_notifications` (mock).
/// /
/// , "스케줄/취소 호출이 올바른 id·시각·페이로드로 이뤄지는가" .
class _FakeScheduler implements SnoozeNotificationScheduler {
final scheduledCalls = <Map<String, Object?>>[];
final cancelledIds = <int>[];
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
scheduledCalls.add({
'conversationId': conversationId,
'title': title,
'body': body,
'at': at,
});
}
@override
Future<void> cancelReminder(int conversationId) async {
cancelledIds.add(conversationId);
}
}
void main() {
late AppDatabase db;
late _FakeScheduler scheduler;
late SnoozeController controller;
setUp(() {
db = AppDatabase.memory();
scheduler = _FakeScheduler();
controller = SnoozeController(db: db, scheduler: scheduler);
});
tearDown(() => db.close());
test('applySnooze persists to DB and schedules with exact id/time/payload', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(
conversationId: 7,
until: until,
title: '답장 마감',
body: '상대#7에게 답장할 시간이에요',
);
expect(await controller.loadSnoozedUntil(7), until);
expect(scheduler.scheduledCalls, hasLength(1));
expect(scheduler.scheduledCalls.single, {
'conversationId': 7,
'title': '답장 마감',
'body': '상대#7에게 답장할 시간이에요',
'at': until,
});
});
test('clearSnooze removes from DB and cancels the scheduled reminder', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(conversationId: 9, until: until, title: 't', body: 'b');
expect(await controller.loadSnoozedUntil(9), isNotNull);
await controller.clearSnooze(9);
expect(await controller.loadSnoozedUntil(9), isNull);
expect(scheduler.cancelledIds, [9]);
});
test('loadAllSnoozes reflects multiple conversations independently', () async {
final until1 = DateTime.utc(2026, 8, 3, 21, 0);
final until2 = DateTime.utc(2026, 8, 4, 9, 0);
await controller.applySnooze(conversationId: 1, until: until1, title: 't', body: 'b');
await controller.applySnooze(conversationId: 2, until: until2, title: 't', body: 'b');
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
expect(scheduler.scheduledCalls, hasLength(2));
});
}

View File

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/services/snooze_service.dart';
void main() {
group('isSnoozePastDue / isSnoozeActive', () {
// "now" (DateTime.now() ).
final now = DateTime(2026, 8, 3, 12, 0);
test('no snooze set -> never past due, never active', () {
expect(isSnoozePastDue(now, null), isFalse);
expect(isSnoozeActive(now, null), isFalse);
});
test('snooze in the future -> active, not past due', () {
final until = now.add(const Duration(hours: 1));
expect(isSnoozePastDue(now, until), isFalse);
expect(isSnoozeActive(now, until), isTrue);
});
test('snooze in the past -> past due, not active', () {
final until = now.subtract(const Duration(minutes: 1));
expect(isSnoozePastDue(now, until), isTrue);
expect(isSnoozeActive(now, until), isFalse);
});
test('snooze exactly at now -> counts as past due (boundary)', () {
expect(isSnoozePastDue(now, now), isTrue);
expect(isSnoozeActive(now, now), isFalse);
});
});
group('resolveSnoozeQuickPick', () {
test('oneHour adds exactly one hour', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.oneHour, now), DateTime(2026, 8, 3, 13, 0));
});
test('thisEvening resolves to 19:00 today when now is before 19:00', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 3, 19, 0));
});
test('thisEvening rolls over to tomorrow 19:00 when now is already past 19:00', () {
final now = DateTime(2026, 8, 3, 20, 30);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('thisEvening rolls over exactly at 19:00 (boundary is not "before")', () {
final now = DateTime(2026, 8, 3, 19, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('tomorrowMorning resolves to 09:00 the next day', () {
final now = DateTime(2026, 8, 3, 23, 45);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.tomorrowMorning, now), DateTime(2026, 8, 4, 9, 0));
});
test('quick pick labels are the fixed Korean copy used in the UI', () {
expect(SnoozeQuickPick.oneHour.label, '1시간 후');
expect(SnoozeQuickPick.thisEvening.label, '저녁에');
expect(SnoozeQuickPick.tomorrowMorning.label, '내일');
});
});
}