Compare commits
7 Commits
9d2e7ec621
...
95422bb012
| Author | SHA1 | Date |
|---|---|---|
|
|
95422bb012 | |
|
|
f9fba46889 | |
|
|
b4a3b4a903 | |
|
|
4182eba9a6 | |
|
|
a8c7b98922 | |
|
|
73dfa2c33c | |
|
|
5733ec4399 |
|
|
@ -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):
|
def build_user_prompt(style_examples, context_lines):
|
||||||
examples = "\n".join(f"- {s}" for s in style_examples)
|
examples = "\n".join(f"- {s}" for s in style_examples)
|
||||||
context = "\n".join(context_lines)
|
context = "\n".join(context_lines)
|
||||||
|
|
@ -61,12 +84,26 @@ def last_incoming_text(context_lines):
|
||||||
return last.split(": ", 1)[1] if ": " in last else last
|
return last.split(": ", 1)[1] if ": " in last else last
|
||||||
|
|
||||||
|
|
||||||
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
|
def draft_reply(
|
||||||
|
style_examples,
|
||||||
|
context_lines,
|
||||||
|
model="gemini-2.5-flash",
|
||||||
|
api_key=None,
|
||||||
|
relationship_tier=None,
|
||||||
|
relationship_note=None,
|
||||||
|
):
|
||||||
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
|
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
|
||||||
|
|
||||||
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
|
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
|
||||||
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
|
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
|
||||||
"ok": text is the generated draft.
|
"ok": text is the generated draft.
|
||||||
|
|
||||||
|
relationship_tier: "close" | "formal" | None (roadmap.md §2.7-B). Only
|
||||||
|
nudges tone -- escalation/identity gating above is unaffected by it.
|
||||||
|
|
||||||
|
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)
|
incoming = last_incoming_text(context_lines)
|
||||||
gate = check_escalation(incoming)
|
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(
|
resp = client.models.generate_content(
|
||||||
model=model,
|
model=model,
|
||||||
contents=build_user_prompt(style_examples, context_lines),
|
contents=build_user_prompt(style_examples, context_lines),
|
||||||
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=system_prompt_for_tier(relationship_tier, relationship_note),
|
||||||
|
max_output_tokens=300,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return "ok", resp.text.strip()
|
return "ok", resp.text.strip()
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class DraftRequest(BaseModel):
|
||||||
history: Optional[List[str]] = None
|
history: Optional[List[str]] = None
|
||||||
k: int = 6
|
k: int = 6
|
||||||
model: str = "gemini-2.5-flash"
|
model: str = "gemini-2.5-flash"
|
||||||
|
relationship_tier: Optional[str] = None
|
||||||
|
relationship_note: Optional[str] = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_exactly_one_style_source(self):
|
def check_exactly_one_style_source(self):
|
||||||
|
|
@ -71,7 +73,13 @@ def draft(req: DraftRequest):
|
||||||
req.history, last_incoming_text(req.context_lines), k=req.k
|
req.history, last_incoming_text(req.context_lines), k=req.k
|
||||||
)
|
)
|
||||||
|
|
||||||
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
|
status, text = draft_reply(
|
||||||
|
style_examples,
|
||||||
|
req.context_lines,
|
||||||
|
model=req.model,
|
||||||
|
relationship_tier=req.relationship_tier,
|
||||||
|
relationship_note=req.relationship_note,
|
||||||
|
)
|
||||||
return DraftResponse(status=status, text=text)
|
return DraftResponse(status=status, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ import sys
|
||||||
import types
|
import types
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from app.generation import build_user_prompt, draft_reply, last_incoming_text
|
from app.generation import (
|
||||||
|
RELATIONSHIP_TIER_INSTRUCTIONS,
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
build_user_prompt,
|
||||||
|
draft_reply,
|
||||||
|
last_incoming_text,
|
||||||
|
system_prompt_for_tier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_last_incoming_text_strips_speaker_prefix():
|
def test_last_incoming_text_strips_speaker_prefix():
|
||||||
|
|
@ -73,3 +80,97 @@ def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch):
|
||||||
_, kwargs = fake_client.models.generate_content.call_args
|
_, kwargs = fake_client.models.generate_content.call_args
|
||||||
assert kwargs["model"] == "gemini-2.5-flash"
|
assert kwargs["model"] == "gemini-2.5-flash"
|
||||||
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]
|
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_for_tier_none_returns_base():
|
||||||
|
assert system_prompt_for_tier(None) == SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_for_tier_close_adds_instruction():
|
||||||
|
prompt = system_prompt_for_tier("close")
|
||||||
|
assert prompt.startswith(SYSTEM_PROMPT)
|
||||||
|
assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_for_tier_formal_adds_instruction():
|
||||||
|
prompt = system_prompt_for_tier("formal")
|
||||||
|
assert prompt.startswith(SYSTEM_PROMPT)
|
||||||
|
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_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"]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
/escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
|
/escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main_module
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
@ -105,3 +106,83 @@ def test_summarize_no_key(monkeypatch):
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["status"] == "no_key"
|
assert body["status"] == "no_key"
|
||||||
assert "토요일 모임 3시로 하자" in body["summary"]
|
assert "토요일 모임 3시로 하자" in body["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_passes_relationship_tier_through_to_draft_reply(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_draft_reply(
|
||||||
|
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, 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
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,20 @@ type createContactRequest struct {
|
||||||
DisplayName string `json:"display_name" binding:"required"`
|
DisplayName string `json:"display_name" binding:"required"`
|
||||||
ContactUserID *uint `json:"contact_user_id"`
|
ContactUserID *uint `json:"contact_user_id"`
|
||||||
RelationshipNote string `json:"relationship_note"`
|
RelationshipNote string `json:"relationship_note"`
|
||||||
|
// RelationshipTier overrides the owner's global default for this
|
||||||
|
// contact (roadmap.md §2.7-B). Nil = use the global default.
|
||||||
|
RelationshipTier *RelationshipTier `json:"relationship_tier"`
|
||||||
|
// 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 {
|
type updateContactRequest struct {
|
||||||
DisplayName string `json:"display_name" binding:"required"`
|
DisplayName string `json:"display_name" binding:"required"`
|
||||||
ContactUserID *uint `json:"contact_user_id"`
|
ContactUserID *uint `json:"contact_user_id"`
|
||||||
RelationshipNote string `json:"relationship_note"`
|
RelationshipNote string `json:"relationship_note"`
|
||||||
|
RelationshipTier *RelationshipTier `json:"relationship_tier"`
|
||||||
|
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func contactJSON(ct Contact) gin.H {
|
func contactJSON(ct Contact) gin.H {
|
||||||
|
|
@ -32,6 +40,8 @@ func contactJSON(ct Contact) gin.H {
|
||||||
"display_name": ct.DisplayName,
|
"display_name": ct.DisplayName,
|
||||||
"contact_user_id": ct.ContactUserID,
|
"contact_user_id": ct.ContactUserID,
|
||||||
"relationship_note": ct.RelationshipNote,
|
"relationship_note": ct.RelationshipNote,
|
||||||
|
"relationship_tier": ct.RelationshipTier,
|
||||||
|
"autonomy_level": ct.AutonomyLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +219,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
"id": conv.ID,
|
"id": conv.ID,
|
||||||
"is_group": conv.IsGroup,
|
"is_group": conv.IsGroup,
|
||||||
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
|
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
|
||||||
|
"twin_disabled_by_flood": conv.TwinDisabledByFlood,
|
||||||
"user_ids": participantIDs,
|
"user_ids": participantIDs,
|
||||||
"created_at": conv.CreatedAt,
|
"created_at": conv.CreatedAt,
|
||||||
"unread_count": unreadCount,
|
"unread_count": unreadCount,
|
||||||
|
|
@ -260,11 +271,21 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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{
|
contact := Contact{
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
ContactUserID: req.ContactUserID,
|
ContactUserID: req.ContactUserID,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
RelationshipNote: req.RelationshipNote,
|
RelationshipNote: req.RelationshipNote,
|
||||||
|
RelationshipTier: req.RelationshipTier,
|
||||||
|
AutonomyLevel: req.AutonomyLevel,
|
||||||
}
|
}
|
||||||
if err := db.Create(&contact).Error; err != nil {
|
if err := db.Create(&contact).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
@ -307,6 +328,14 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
var contact Contact
|
||||||
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||||
|
|
@ -319,6 +348,8 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
contact.DisplayName = req.DisplayName
|
contact.DisplayName = req.DisplayName
|
||||||
contact.ContactUserID = req.ContactUserID
|
contact.ContactUserID = req.ContactUserID
|
||||||
contact.RelationshipNote = req.RelationshipNote
|
contact.RelationshipNote = req.RelationshipNote
|
||||||
|
contact.RelationshipTier = req.RelationshipTier
|
||||||
|
contact.AutonomyLevel = req.AutonomyLevel
|
||||||
if err := db.Save(&contact).Error; err != nil {
|
if err := db.Save(&contact).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,15 @@ func newAIServiceClient() *AIServiceClient {
|
||||||
|
|
||||||
type draftRequest struct {
|
type draftRequest struct {
|
||||||
ContextLines []string `json:"context_lines"`
|
ContextLines []string `json:"context_lines"`
|
||||||
|
// RelationshipTier (roadmap.md §2.7-B): "close" | "formal". Empty is
|
||||||
|
// treated by ai-service as "no tier info" and falls back to its own
|
||||||
|
// default tone, same as before this field existed.
|
||||||
|
RelationshipTier string `json:"relationship_tier,omitempty"`
|
||||||
|
// 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"`
|
StyleExamples []string `json:"style_examples,omitempty"`
|
||||||
History []string `json:"history,omitempty"`
|
History []string `json:"history,omitempty"`
|
||||||
K int `json:"k,omitempty"`
|
K int `json:"k,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,9 @@ type sendMessageRequest struct {
|
||||||
|
|
||||||
type updateTwinSettingsRequest struct {
|
type updateTwinSettingsRequest struct {
|
||||||
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
|
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
|
||||||
|
// RelationshipTier is optional so existing autonomy-only PATCH calls
|
||||||
|
// keep working unchanged (roadmap.md §2.7-B). Nil = leave it as-is.
|
||||||
|
RelationshipTier *RelationshipTier `json:"relationship_tier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createWhitelistRuleRequest struct {
|
type createWhitelistRuleRequest struct {
|
||||||
|
|
@ -202,7 +205,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal})
|
||||||
|
|
||||||
if !demo {
|
if !demo {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
@ -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
|
// words and are never gated. On any doubt (AI service unreachable
|
||||||
// or erroring) we fail closed and block the send. Peer veto is
|
// or erroring) we fail closed and block the send. Peer veto is
|
||||||
// checked first (it's a total kill switch for this conversation,
|
// checked first (it's a total kill switch for this conversation,
|
||||||
// independent of content), then escalation, then autonomy level --
|
// independent of content), then the group-chat block, then flood
|
||||||
// none of the later checks can override an earlier block.
|
// detection, then escalation, then autonomy level -- none of the
|
||||||
|
// later checks can override an earlier block.
|
||||||
if req.SenderMode == SenderTwin {
|
if req.SenderMode == SenderTwin {
|
||||||
if conversation.TwinDisabledByPeer {
|
if conversation.TwinDisabledByPeer {
|
||||||
runtimeMetrics.recordTwinBlocked()
|
runtimeMetrics.recordTwinBlocked()
|
||||||
|
|
@ -273,6 +277,41 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
return
|
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)
|
result, err := ai.checkEscalation(req.Text)
|
||||||
runtimeMetrics.recordEscalate(err)
|
runtimeMetrics.recordEscalate(err)
|
||||||
if err != nil {
|
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
|
// Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing
|
||||||
// settings fail closed to L0, the documented default.
|
// settings fail closed to L0, the documented default. roadmap.md
|
||||||
level := AutonomyL0
|
// §2.7-D: the level itself can be overridden per-contact (1:1
|
||||||
var settings TwinSettings
|
// only -- group conversations already returned above and never
|
||||||
if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil {
|
// reach this point), so resolve it the same way relationship
|
||||||
level = settings.AutonomyLevel
|
// tier is resolved instead of reading only the global default.
|
||||||
}
|
level := resolveAutonomyLevel(db, req.SenderID, convID)
|
||||||
|
|
||||||
switch level {
|
switch level {
|
||||||
case AutonomyL0:
|
case AutonomyL0:
|
||||||
|
|
@ -413,9 +452,25 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관계별 페르소나(roadmap.md §2.7-B): optional auth -- this endpoint
|
||||||
|
// has never required a session, so callers without one (existing
|
||||||
|
// tests, or a future anonymous path) still work exactly as before,
|
||||||
|
// just without tier resolution (falls back to "formal").
|
||||||
|
tier := RelationshipFormal
|
||||||
|
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()
|
started := time.Now()
|
||||||
result, err := ai.requestDraft(draftRequest{
|
result, err := ai.requestDraft(draftRequest{
|
||||||
ContextLines: req.ContextLines,
|
ContextLines: req.ContextLines,
|
||||||
|
RelationshipTier: string(tier),
|
||||||
|
RelationshipNote: note,
|
||||||
StyleExamples: req.StyleExamples,
|
StyleExamples: req.StyleExamples,
|
||||||
History: req.History,
|
History: req.History,
|
||||||
K: req.K,
|
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})
|
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) {
|
r.PATCH("/users/:id/twin-settings", func(c *gin.Context) {
|
||||||
userID, ok := parseUintParam(c, "id")
|
userID, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var settings TwinSettings
|
var settings TwinSettings
|
||||||
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
|
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
|
||||||
|
|
@ -479,12 +564,19 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings.AutonomyLevel = req.AutonomyLevel
|
settings.AutonomyLevel = req.AutonomyLevel
|
||||||
|
if req.RelationshipTier != nil {
|
||||||
|
settings.RelationshipTier = *req.RelationshipTier
|
||||||
|
}
|
||||||
if err := db.Save(&settings).Error; err != nil {
|
if err := db.Save(&settings).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": userID,
|
||||||
|
"autonomy_level": settings.AutonomyLevel,
|
||||||
|
"relationship_tier": settings.RelationshipTier,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {
|
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ func mockAIService(t *testing.T) *httptest.Server {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
|
// Echo relationship_tier 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":
|
case "/escalate/check":
|
||||||
// Mirrors escalation_filter.py's rule set closely enough to
|
// Mirrors escalation_filter.py's rule set closely enough to
|
||||||
// exercise core-backend's gating logic; the rules themselves
|
// exercise core-backend's gating logic; the rules themselves
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,27 @@ const (
|
||||||
AutonomyL2 AutonomyLevel = "L2"
|
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 {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
InviteCode string `gorm:"uniqueIndex;not null"`
|
InviteCode string `gorm:"uniqueIndex;not null"`
|
||||||
|
|
@ -55,6 +76,16 @@ type Contact struct {
|
||||||
ContactUserID *uint
|
ContactUserID *uint
|
||||||
DisplayName string `gorm:"not null"`
|
DisplayName string `gorm:"not null"`
|
||||||
RelationshipNote string
|
RelationshipNote string
|
||||||
|
// RelationshipTier overrides the owner's global TwinSettings.RelationshipTier
|
||||||
|
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
|
||||||
|
// "use the global default".
|
||||||
|
RelationshipTier *RelationshipTier
|
||||||
|
// 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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,10 +94,17 @@ type Contact struct {
|
||||||
// it lives here rather than on Contact. Set by POST /conversations/:id/veto
|
// 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
|
// when the counterpart asks to talk to the human only; checked before any
|
||||||
// twin auto-send in that conversation (main.go).
|
// 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 {
|
type Conversation struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
IsGroup bool `gorm:"not null;default:false"`
|
IsGroup bool `gorm:"not null;default:false"`
|
||||||
TwinDisabledByPeer bool `gorm:"not null;default:false"`
|
TwinDisabledByPeer bool `gorm:"not null;default:false"`
|
||||||
|
TwinDisabledByFlood bool `gorm:"not null;default:false"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +136,7 @@ type TwinSettings struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
UserID uint `gorm:"uniqueIndex;not null"`
|
UserID uint `gorm:"uniqueIndex;not null"`
|
||||||
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
|
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
|
||||||
|
RelationshipTier RelationshipTier `gorm:"not null;default:formal"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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 ""
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,8 +43,37 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
다크 캔버스 `#141418` · web/core/ai 재배포 완료
|
다크 캔버스 `#141418` · web/core/ai 재배포 완료
|
||||||
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료·프로덕션 반영** (2026-08-03) —
|
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료·프로덕션 반영** (2026-08-03) —
|
||||||
그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커,
|
그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커,
|
||||||
안 본 배지, 그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지).
|
안 본 배지, 그룹 트윈 발송 서버측 차단까지. C2(관계별 페르소나)도 **완료**
|
||||||
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
(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 순서
|
### NEXT 순서
|
||||||
|
|
||||||
|
|
@ -175,9 +204,12 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
||||||
|
|
||||||
### Track C — 콘텐츠 갭 (PRD P0 대비 미구현, 2026-07-31 발견)
|
### Track C — 콘텐츠 갭 (PRD P0 대비 미구현, 2026-07-31 발견)
|
||||||
|
|
||||||
`roadmap.md` §2.7과 동일 항목. `PRD.md` §3.1 P0 표 대조 결과 발견. **우선순위: C1(단톡 따라잡기)
|
`roadmap.md` §2.7과 동일 항목. `PRD.md` §3.1 P0 표 대조 결과 발견(2026-07-31), 2026-08-03
|
||||||
→ C2(관계별 페르소나) → C3(스팸 감지)** — 단톡 따라잡기는 v1 MVP 시나리오 2개 중 하나인데 현재
|
2차 재분석으로 D/E/F 추가. **우선순위: C1(단톡 따라잡기) → C2(관계별 페르소나) → C3(스팸 감지)
|
||||||
0% 구현이라 완성도 공백이 가장 큼.
|
→ C4(자율성 상대별 예외) → C5(관계 메모 반영) → C6(답장 마감 알림)** — 단톡 따라잡기는 v1 MVP
|
||||||
|
시나리오 2개 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼; C4는 P0 명시 항목이지만
|
||||||
|
오버라이드 메커니즘 자체가 없어 C1~C3 다음; C5는 필드/UI가 이미 있고 프롬프트 주입만 빠져
|
||||||
|
비용이 가장 작음; C6은 P1이자 신규 구현 분량이 가장 커서 맨 마지막.
|
||||||
|
|
||||||
| ID | 작업 | Status | 완료 조건 |
|
| 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-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
|
||||||
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
|
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
|
||||||
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
|
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
|
||||||
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 |
|
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | **done** (2026-08-03) | `core-backend/models.go` `TwinSettings`(전역 기본값)·`Contact`(상대별 오버라이드) 확장 |
|
||||||
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` |
|
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 |
|
||||||
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 |
|
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI |
|
||||||
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | todo | `ai-service/app/generation.py` |
|
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) |
|
||||||
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) |
|
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | **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** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 |
|
| **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)
|
### 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 탭은 테스터)
|
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
|
||||||
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
|
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
|
||||||
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
|
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
|
||||||
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음:
|
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03),
|
||||||
**Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)** →
|
~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done**
|
||||||
N4-4 스모크 → Android UI QA (N4-5~10)
|
(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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
127
docs/roadmap.md
127
docs/roadmap.md
|
|
@ -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** (이유:
|
`PRD.md` §3.1 P0 표와 실제 코드를 대조해 발견. **우선순위: 2.7-A → 2.7-B → 2.7-C → 2.7-D →
|
||||||
단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼;
|
2.7-E → 2.7-F** (이유: 단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라
|
||||||
페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음).
|
완성도 공백이 가장 큼; 페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소
|
||||||
|
버전"이라 상대적으로 작음; 자율성 상대별 예외는 P0에 명시된 항목인데 관계별 페르소나와 달리
|
||||||
|
오버라이드 메커니즘 자체가 없어서 우선순위 4; 관계 메모는 필드/UI는 이미 있고 프롬프트 주입
|
||||||
|
한 줄만 빠진 상태라 비용이 가장 작음; 답장 마감 알림은 P1이자 셋 중 가장 새로 만들어야 할
|
||||||
|
분량이 커서 맨 마지막).
|
||||||
|
|
||||||
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
|
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
|
||||||
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
|
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
|
||||||
|
|
@ -152,18 +157,107 @@
|
||||||
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
|
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
|
||||||
있는 동안은 계속 읽음으로 따라가도 됨
|
있는 동안은 계속 읽음으로 따라가도 됨
|
||||||
|
|
||||||
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재
|
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종 — **완료** 2026-08-03)
|
||||||
`TwinSettings`엔 `AutonomyLevel`만 있고 페르소나 필드 없음)
|
- [x] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `TwinSettings`에
|
||||||
- [ ] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `core-backend/models.go`
|
추가, 신규 유저 기본값은 안전 우선으로 `formal`. `Contact`에도 상대별 오버라이드 필드 추가
|
||||||
- [ ] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart` (현재는 말투 샘플 4개뿐)
|
- [x] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역
|
||||||
- [ ] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart`, 자율성 레벨의 상대별 예외와 동일 패턴
|
기본값 선택(`SegmentedButton`)
|
||||||
- [ ] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py` 시스템 프롬프트에 주입
|
- [x] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart` 연락처 추가/수정 다이얼로그에
|
||||||
|
`_RelationshipTierPicker`(기본값 사용/가까운 사이/공식적인 사이 3-way 칩), 자율성 화면에도
|
||||||
|
전역 기본값 변경 UI 추가
|
||||||
|
- [x] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py`
|
||||||
|
`RELATIONSHIP_TIER_INSTRUCTIONS` + `system_prompt_for_tier()`. `core-backend`가
|
||||||
|
`POST /conversations/:id/draft` 호출마다 `resolveRelationshipTier()`로 해석(연락처 오버라이드
|
||||||
|
→ 전역 기본값 → `formal`) 해서 `ai-service`로 전달. 그룹 대화는 상대가 여럿이라 항상 전역
|
||||||
|
기본값만 사용. 이 엔드포인트가 원래 인증을 요구하지 않던 걸 깨지 않도록 `currentUser(..., false)`로
|
||||||
|
선택적 인증 처리 — 토큰 없는 기존 호출도 그대로 동작(티어 해석만 스킵)
|
||||||
|
|
||||||
**2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로
|
**2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로
|
||||||
명시 — 현재 코드 전체에 관련 로직 0건)
|
명시 — **완료** 2026-08-03)
|
||||||
- [ ] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — 에스컬레이션 하드게이트와
|
- [x] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — `core-backend/flood_detect.go`
|
||||||
동일 위치(우회 불가 지점)에 추가. `ai-service/app/escalation_filter.py` 또는 신규 규칙
|
(`floodMessageThreshold = 5`건 / `floodWindow = 2분`, **PoC 검증값이 아니라 안전 최소값으로
|
||||||
- [ ] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 재사용
|
명시한 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`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건
|
테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건
|
||||||
|
|
@ -199,8 +293,9 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
§3(사람 PoC) 이후**
|
§3(사람 PoC) 이후**
|
||||||
- 5-2. [x] 화이트리스트 규칙 CRUD API
|
- 5-2. [x] 화이트리스트 규칙 CRUD API
|
||||||
6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
|
6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
|
||||||
- 6-1. [ ] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별
|
- 6-1. [x] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별
|
||||||
페르소나 → C 스팸 감지**, 2026-07-31 발견). §3 사람 PoC보다 먼저 끝내야 함 — PRD가 요구하는
|
페르소나 → C 스팸 감지**, 2026-07-31 발견 — A/B/C 모두 완료 2026-08-03). §3 사람 PoC보다
|
||||||
|
먼저 끝내야 함 — PRD가 요구하는
|
||||||
v1 P0 범위이므로 §4 순서상 D(사람 PoC) 앞
|
v1 P0 범위이므로 §4 순서상 D(사람 PoC) 앞
|
||||||
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
|
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import 'package:sqlite3/open.dart';
|
||||||
|
|
||||||
import 'tables.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 {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase(super.e, {this.encrypted = false});
|
AppDatabase(super.e, {this.encrypted = false});
|
||||||
|
|
||||||
|
|
@ -23,7 +23,18 @@ class AppDatabase extends _$AppDatabase {
|
||||||
final bool encrypted;
|
final bool encrypted;
|
||||||
|
|
||||||
@override
|
@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).
|
/// In-memory DB for unit tests (no SQLCipher / filesystem).
|
||||||
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
|
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
|
||||||
|
|
@ -76,6 +87,42 @@ class AppDatabase extends _$AppDatabase {
|
||||||
Future<void> setBoolKv(String key, bool value) async {
|
Future<void> setBoolKv(String key, bool value) async {
|
||||||
await setKv(key, value ? '1' : '0');
|
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';
|
const _kDbPassphrase = 'db_passphrase_v1';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
_$AppDatabase(QueryExecutor e) : super(e);
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
|
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
|
||||||
late final $LocalKvTable localKv = $LocalKvTable(this);
|
late final $LocalKvTable localKv = $LocalKvTable(this);
|
||||||
|
late final $ConversationSnoozesTable conversationSnoozes =
|
||||||
|
$ConversationSnoozesTable(this);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
@override
|
@override
|
||||||
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
|
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||||
|
toneSamples,
|
||||||
|
localKv,
|
||||||
|
conversationSnoozes,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
||||||
|
|
@ -862,6 +1102,169 @@ typedef $$LocalKvTableProcessedTableManager =
|
||||||
LocalKvData,
|
LocalKvData,
|
||||||
PrefetchHooks Function()
|
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 {
|
class $AppDatabaseManager {
|
||||||
final _$AppDatabase _db;
|
final _$AppDatabase _db;
|
||||||
|
|
@ -870,4 +1273,6 @@ class $AppDatabaseManager {
|
||||||
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
|
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
|
||||||
$$LocalKvTableTableManager get localKv =>
|
$$LocalKvTableTableManager get localKv =>
|
||||||
$$LocalKvTableTableManager(_db, _db.localKv);
|
$$LocalKvTableTableManager(_db, _db.localKv);
|
||||||
|
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
|
||||||
|
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ class AppDatabase {
|
||||||
|
|
||||||
final Map<String, String> _kv = {};
|
final Map<String, String> _kv = {};
|
||||||
List<String> _toneSamples = [];
|
List<String> _toneSamples = [];
|
||||||
|
final Map<int, DateTime> _snoozes = {};
|
||||||
|
|
||||||
factory AppDatabase.memory() => AppDatabase(encrypted: false);
|
factory AppDatabase.memory() => AppDatabase(encrypted: false);
|
||||||
|
|
||||||
|
|
@ -38,5 +39,19 @@ class AppDatabase {
|
||||||
await setKv(key, value ? '1' : '0');
|
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 {}
|
Future<void> close() async {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,16 @@ class LocalKv extends Table {
|
||||||
@override
|
@override
|
||||||
Set<Column<Object>> get primaryKey => {key};
|
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};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,26 @@ enum SenderMode { human, twin }
|
||||||
// ignore: constant_identifier_names
|
// ignore: constant_identifier_names
|
||||||
enum AutonomyLevel { L0, L1, L2 }
|
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 {
|
class User {
|
||||||
User({required this.id, required this.displayName, required this.inviteCode});
|
User({required this.id, required this.displayName, required this.inviteCode});
|
||||||
|
|
||||||
|
|
@ -19,9 +39,10 @@ class User {
|
||||||
}
|
}
|
||||||
|
|
||||||
class TwinSettings {
|
class TwinSettings {
|
||||||
TwinSettings({required this.autonomyLevel});
|
TwinSettings({required this.autonomyLevel, required this.relationshipTier});
|
||||||
|
|
||||||
final AutonomyLevel autonomyLevel;
|
final AutonomyLevel autonomyLevel;
|
||||||
|
final RelationshipTier relationshipTier;
|
||||||
|
|
||||||
factory TwinSettings.fromJson(Map<String, dynamic> json) {
|
factory TwinSettings.fromJson(Map<String, dynamic> json) {
|
||||||
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
|
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
|
||||||
|
|
@ -30,6 +51,7 @@ class TwinSettings {
|
||||||
(e) => e.name == raw,
|
(e) => e.name == raw,
|
||||||
orElse: () => AutonomyLevel.L0,
|
orElse: () => AutonomyLevel.L0,
|
||||||
),
|
),
|
||||||
|
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +62,7 @@ class ConversationSummary {
|
||||||
required this.isGroup,
|
required this.isGroup,
|
||||||
required this.userIds,
|
required this.userIds,
|
||||||
required this.twinDisabledByPeer,
|
required this.twinDisabledByPeer,
|
||||||
|
this.twinDisabledByFlood = false,
|
||||||
this.unreadCount = 0,
|
this.unreadCount = 0,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -48,6 +71,9 @@ class ConversationSummary {
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final List<int> userIds;
|
final List<int> userIds;
|
||||||
final bool twinDisabledByPeer;
|
final bool twinDisabledByPeer;
|
||||||
|
// roadmap.md §2.7-C 스팸/도배 감지: 자동으로 켜지지만(사람의 선택이 아님)
|
||||||
|
// twinDisabledByPeer와 달리 되돌릴 수 있음 (POST /conversations/:id/flood-reset).
|
||||||
|
final bool twinDisabledByFlood;
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
|
@ -58,6 +84,7 @@ class ConversationSummary {
|
||||||
isGroup: json['is_group'] as bool? ?? false,
|
isGroup: json['is_group'] as bool? ?? false,
|
||||||
userIds: rawIds.map((e) => e as int).toList(),
|
userIds: rawIds.map((e) => e as int).toList(),
|
||||||
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
|
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,
|
unreadCount: json['unread_count'] as int? ?? 0,
|
||||||
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
|
||||||
);
|
);
|
||||||
|
|
@ -78,18 +105,36 @@ class Contact {
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
this.contactUserId,
|
this.contactUserId,
|
||||||
this.relationshipNote = '',
|
this.relationshipNote = '',
|
||||||
|
this.relationshipTier,
|
||||||
|
this.autonomyLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final int? contactUserId;
|
final int? contactUserId;
|
||||||
final String relationshipNote;
|
final String relationshipNote;
|
||||||
|
/// Per-contact override of the global relationship tier (roadmap.md
|
||||||
|
/// §2.7-B). Null means "use the global default".
|
||||||
|
final RelationshipTier? relationshipTier;
|
||||||
|
/// 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(
|
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
|
||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
displayName: json['display_name'] as String? ?? '',
|
displayName: json['display_name'] as String? ?? '',
|
||||||
contactUserId: json['contact_user_id'] as int?,
|
contactUserId: json['contact_user_id'] as int?,
|
||||||
relationshipNote: json['relationship_note'] as String? ?? '',
|
relationshipNote: json['relationship_note'] as String? ?? '',
|
||||||
|
relationshipTier: json['relationship_tier'] == null
|
||||||
|
? null
|
||||||
|
: RelationshipTier.fromJson(json['relationship_tier'] as String?),
|
||||||
|
autonomyLevel: json['autonomy_level'] == null
|
||||||
|
? null
|
||||||
|
: AutonomyLevel.values.firstWhere(
|
||||||
|
(e) => e.name == json['autonomy_level'] as String?,
|
||||||
|
orElse: () => AutonomyLevel.L0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,21 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
Text('기본 관계 설정', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'연락처별로 다르게 설정하지 않은 상대에게는 이 기본값이 적용됩니다.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<RelationshipTier>(
|
||||||
|
segments: RelationshipTier.values
|
||||||
|
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
|
||||||
|
.toList(),
|
||||||
|
selected: {session.relationshipTier},
|
||||||
|
onSelectionChanged: (s) => session.setRelationshipTier(s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
|
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,28 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
|
import '../services/snooze_service.dart';
|
||||||
import '../services/ws_client.dart';
|
import '../services/ws_client.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/message_bubble.dart';
|
import '../widgets/message_bubble.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
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 int conversationId;
|
||||||
final String? title;
|
final String? title;
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
|
// roadmap.md §2.7-C 도배 감지: 목록 화면이 이미 알고 있는 초기 상태를 넘겨
|
||||||
|
// 받아, 채팅방을 열자마자 (실패한 발송을 기다리지 않고) 배너 + 재개 버튼을
|
||||||
|
// 보여줄 수 있게 한다.
|
||||||
|
final bool twinDisabledByFlood;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
|
@ -32,15 +43,102 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
DraftResult? _pendingDraft;
|
DraftResult? _pendingDraft;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
bool _loadingHistory = true;
|
bool _loadingHistory = true;
|
||||||
|
late bool _floodBlocked;
|
||||||
late final ApiClient _api;
|
late final ApiClient _api;
|
||||||
|
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
||||||
|
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
||||||
|
DateTime? _snoozedUntil;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_api = context.read<SessionState>().api;
|
_api = context.read<SessionState>().api;
|
||||||
|
_floodBlocked = widget.twinDisabledByFlood;
|
||||||
|
if (_floodBlocked) {
|
||||||
|
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
||||||
|
}
|
||||||
_socket = ConversationSocket(widget.conversationId)..connect();
|
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||||
_sub = _socket!.events.listen(_onEvent);
|
_sub = _socket!.events.listen(_onEvent);
|
||||||
_loadHistory();
|
_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 {
|
Future<void> _loadHistory() async {
|
||||||
|
|
@ -149,6 +247,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
setState(() => _messages.add(msg));
|
setState(() => _messages.add(msg));
|
||||||
}
|
}
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
|
// roadmap.md §2.7-F: 답장을 실제로 보냈으니 걸려 있던 스누즈는 자동 해제.
|
||||||
|
_clearSnoozeQuiet();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -230,13 +330,42 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
|
// roadmap.md §2.7-F: 와카뷰가 대신 답장을 보냈어도 답장은 답장이므로 스누즈 해제.
|
||||||
|
_clearSnoozeQuiet();
|
||||||
} on ApiException catch (e) {
|
} 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 {
|
} finally {
|
||||||
setState(() => _busy = false);
|
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() {
|
void _rejectDraft() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingDraft = null;
|
_pendingDraft = null;
|
||||||
|
|
@ -449,6 +578,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
onPressed: _openSummary,
|
onPressed: _openSummary,
|
||||||
icon: const Icon(Icons.summarize_outlined),
|
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(
|
IconButton(
|
||||||
tooltip: '거부권 (와카뷰 자동응대 중단)',
|
tooltip: '거부권 (와카뷰 자동응대 중단)',
|
||||||
onPressed: _busy ? null : _veto,
|
onPressed: _busy ? null : _veto,
|
||||||
|
|
@ -463,9 +599,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
||||||
content: Text(_banner!),
|
content: Text(_banner!),
|
||||||
actions: [
|
actions: [
|
||||||
|
// 도배 감지 자동중단의 one-tap undo (AGENTS.md 안전 불변식) —
|
||||||
|
// 거부권과 달리 되돌릴 수 있으므로 여기서 바로 재개 가능.
|
||||||
|
if (_floodBlocked)
|
||||||
|
TextButton(onPressed: _busy ? null : _resumeFlood, child: const Text('자동응대 재개')),
|
||||||
TextButton(onPressed: () => setState(() => _banner = null), 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(
|
Expanded(
|
||||||
child: _loadingHistory
|
child: _loadingHistory
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
final noteCtrl = TextEditingController();
|
final noteCtrl = TextEditingController();
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
final myId = session.user?.id;
|
final myId = session.user?.id;
|
||||||
|
RelationshipTier? tierOverride;
|
||||||
|
AutonomyLevel? autonomyOverride;
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: const Text('연락처 추가'),
|
title: const Text('연락처 추가'),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -82,6 +85,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
controller: noteCtrl,
|
controller: noteCtrl,
|
||||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_RelationshipTierPicker(
|
||||||
|
value: tierOverride,
|
||||||
|
onChanged: (t) => setDialogState(() => tierOverride = t),
|
||||||
|
),
|
||||||
|
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('추가')),
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (ok != true || !mounted) return;
|
if (ok != true || !mounted) return;
|
||||||
final name = nameCtrl.text.trim();
|
final name = nameCtrl.text.trim();
|
||||||
|
|
@ -109,6 +127,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
displayName: name,
|
displayName: name,
|
||||||
contactUserId: peer,
|
contactUserId: peer,
|
||||||
relationshipNote: noteCtrl.text.trim(),
|
relationshipNote: noteCtrl.text.trim(),
|
||||||
|
relationshipTier: tierOverride,
|
||||||
|
autonomyLevel: autonomyOverride,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_contacts = [..._contacts, created];
|
_contacts = [..._contacts, created];
|
||||||
|
|
@ -149,9 +169,14 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
);
|
);
|
||||||
final noteCtrl = TextEditingController(text: contact.relationshipNote);
|
final noteCtrl = TextEditingController(text: contact.relationshipNote);
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
|
// PATCH는 전체 교체라 매번 이 값을 그대로 다시 보낸다 — 안 보내면(=이전
|
||||||
|
// 코드처럼 필드 자체를 안 넣으면) 서버가 기존 오버라이드를 null로 되돌림.
|
||||||
|
RelationshipTier? tierOverride = contact.relationshipTier;
|
||||||
|
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
|
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -184,6 +209,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
controller: noteCtrl,
|
controller: noteCtrl,
|
||||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_RelationshipTierPicker(
|
||||||
|
value: tierOverride,
|
||||||
|
onChanged: (t) => setDialogState(() => tierOverride = t),
|
||||||
|
),
|
||||||
|
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('저장')),
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (ok != true || !mounted || session.user == null) return;
|
if (ok != true || !mounted || session.user == null) return;
|
||||||
final name = nameCtrl.text.trim();
|
final name = nameCtrl.text.trim();
|
||||||
|
|
@ -212,6 +252,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
displayName: name,
|
displayName: name,
|
||||||
contactUserId: peer,
|
contactUserId: peer,
|
||||||
relationshipNote: noteCtrl.text.trim(),
|
relationshipNote: noteCtrl.text.trim(),
|
||||||
|
relationshipTier: tierOverride,
|
||||||
|
autonomyLevel: autonomyOverride,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
|
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
|
||||||
|
|
@ -333,6 +375,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
|
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
|
||||||
: [
|
: [
|
||||||
'사용자 #${c.contactUserId}',
|
'사용자 #${c.contactUserId}',
|
||||||
|
if (c.relationshipTier != null) c.relationshipTier!.label,
|
||||||
|
if (c.autonomyLevel != null) c.autonomyLevel!.label,
|
||||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||||
].join(' · '),
|
].join(' · '),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
|
@ -381,3 +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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
|
import '../services/snooze_service.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/my_user_id_chip.dart';
|
import '../widgets/my_user_id_chip.dart';
|
||||||
|
|
@ -22,6 +23,9 @@ class ConversationListScreen extends StatefulWidget {
|
||||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
List<ConversationSummary> _rooms = [];
|
List<ConversationSummary> _rooms = [];
|
||||||
Map<int, String> _peerNames = {};
|
Map<int, String> _peerNames = {};
|
||||||
|
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
||||||
|
// 서버 응답(ConversationSummary)에는 없는 순수 온디바이스 상태라 로컬 DB에서 별도 로드.
|
||||||
|
Map<int, DateTime> _snoozes = {};
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
|
@ -53,9 +57,11 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
// Names are optional enrichment.
|
// Names are optional enrichment.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
|
||||||
setState(() {
|
setState(() {
|
||||||
_rooms = list;
|
_rooms = list;
|
||||||
_peerNames = names;
|
_peerNames = names;
|
||||||
|
_snoozes = snoozes;
|
||||||
});
|
});
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
|
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) {
|
String _titleFor(ConversationSummary room, int? me) {
|
||||||
if (me == null) return '대화방 #${room.id}';
|
if (me == null) return '대화방 #${room.id}';
|
||||||
final peers = room.userIds.where((id) => id != me).toList();
|
final peers = room.userIds.where((id) => id != me).toList();
|
||||||
|
|
@ -77,6 +92,9 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
|
|
||||||
String _subtitleFor(ConversationSummary room, int? me) {
|
String _subtitleFor(ConversationSummary room, int? me) {
|
||||||
if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함';
|
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 peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList();
|
||||||
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
|
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
|
||||||
return '$peerPart · 방 #${room.id}';
|
return '$peerPart · 방 #${room.id}';
|
||||||
|
|
@ -332,9 +350,19 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final room in _rooms)
|
for (final room in _rooms)
|
||||||
Material(
|
Builder(
|
||||||
color: Colors.transparent,
|
builder: (context) {
|
||||||
child: InkWell(
|
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 {
|
onTap: () async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -342,22 +370,65 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
conversationId: room.id,
|
conversationId: room.id,
|
||||||
title: _titleFor(room, me),
|
title: _titleFor(room, me),
|
||||||
isGroup: room.isGroup,
|
isGroup: room.isGroup,
|
||||||
|
twinDisabledByFlood: room.twinDisabledByFlood,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await _load();
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 22,
|
radius: 22,
|
||||||
backgroundColor: _avatarColor(context, room.id),
|
backgroundColor: avatarColor,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: _onAvatarColor(context, room.id),
|
color: onAvatarColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -366,30 +437,52 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_titleFor(room, me),
|
title,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (room.twinDisabledByPeer) ...[
|
if (blockedColor) ...[
|
||||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
Icon(
|
||||||
|
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
|
||||||
|
size: 13,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_subtitleFor(room, me),
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: room.twinDisabledByPeer
|
color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
|
||||||
? 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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/models.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/primary_gradient_button.dart';
|
import '../widgets/primary_gradient_button.dart';
|
||||||
|
|
||||||
|
|
@ -16,16 +17,19 @@ class OnboardingToneScreen extends StatefulWidget {
|
||||||
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||||
final _samples = List.generate(4, (_) => TextEditingController());
|
final _samples = List.generate(4, (_) => TextEditingController());
|
||||||
var _seeded = false;
|
var _seeded = false;
|
||||||
|
RelationshipTier _tier = RelationshipTier.formal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_seeded) return;
|
if (_seeded) return;
|
||||||
_seeded = true;
|
_seeded = true;
|
||||||
final existing = context.read<SessionState>().styleExamples;
|
final session = context.read<SessionState>();
|
||||||
|
final existing = session.styleExamples;
|
||||||
for (var i = 0; i < existing.length && i < _samples.length; i++) {
|
for (var i = 0; i < existing.length && i < _samples.length; i++) {
|
||||||
_samples[i].text = existing[i];
|
_samples[i].text = existing[i];
|
||||||
}
|
}
|
||||||
|
_tier = session.relationshipTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -42,6 +46,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||||
_samples.map((c) => c.text).toList(),
|
_samples.map((c) => c.text).toList(),
|
||||||
markDone: markDone,
|
markDone: markDone,
|
||||||
);
|
);
|
||||||
|
await session.setRelationshipTier(_tier);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -84,7 +89,22 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
|
Text('기본 관계 설정', style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'와카뷰가 초안을 쓸 때 기본으로 쓸 말투 격식이에요. 상대별로 나중에 연락처에서 따로 바꿀 수 있어요.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SegmentedButton<RelationshipTier>(
|
||||||
|
segments: RelationshipTier.values
|
||||||
|
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
|
||||||
|
.toList(),
|
||||||
|
selected: {_tier},
|
||||||
|
onSelectionChanged: (s) => setState(() => _tier = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
PrimaryGradientButton(
|
PrimaryGradientButton(
|
||||||
label: '이 말투로 시작',
|
label: '이 말투로 시작',
|
||||||
onPressed: () => _save(markDone: true),
|
onPressed: () => _save(markDone: true),
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,15 @@ class ApiClient {
|
||||||
required String displayName,
|
required String displayName,
|
||||||
int? contactUserId,
|
int? contactUserId,
|
||||||
String relationshipNote = '',
|
String relationshipNote = '',
|
||||||
|
RelationshipTier? relationshipTier,
|
||||||
|
AutonomyLevel? autonomyLevel,
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _json('POST', '/users/$userId/contacts', body: {
|
final json = await _json('POST', '/users/$userId/contacts', body: {
|
||||||
'display_name': displayName,
|
'display_name': displayName,
|
||||||
if (contactUserId != null) 'contact_user_id': contactUserId,
|
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||||
'relationship_note': relationshipNote,
|
'relationship_note': relationshipNote,
|
||||||
|
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||||
|
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
|
||||||
});
|
});
|
||||||
return Contact.fromJson(json);
|
return Contact.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
@ -133,11 +137,15 @@ class ApiClient {
|
||||||
required String displayName,
|
required String displayName,
|
||||||
int? contactUserId,
|
int? contactUserId,
|
||||||
String relationshipNote = '',
|
String relationshipNote = '',
|
||||||
|
RelationshipTier? relationshipTier,
|
||||||
|
AutonomyLevel? autonomyLevel,
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
|
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
|
||||||
'display_name': displayName,
|
'display_name': displayName,
|
||||||
if (contactUserId != null) 'contact_user_id': contactUserId,
|
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||||
'relationship_note': relationshipNote,
|
'relationship_note': relationshipNote,
|
||||||
|
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||||
|
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
|
||||||
});
|
});
|
||||||
return Contact.fromJson(json);
|
return Contact.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +193,13 @@ class ApiClient {
|
||||||
await _json('POST', '/conversations/$conversationId/veto');
|
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.
|
/// 단톡 따라잡기(roadmap.md §2.7-A): advances the caller's read marker.
|
||||||
Future<void> markRead(int conversationId, int messageId) async {
|
Future<void> markRead(int conversationId, int messageId) async {
|
||||||
await _json('POST', '/conversations/$conversationId/read', body: {
|
await _json('POST', '/conversations/$conversationId/read', body: {
|
||||||
|
|
@ -201,9 +216,14 @@ class ApiClient {
|
||||||
await _json('POST', '/messages/$messageId/retract');
|
await _json('POST', '/messages/$messageId/retract');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TwinSettings> patchTwinSettings(int userId, AutonomyLevel level) async {
|
Future<TwinSettings> patchTwinSettings({
|
||||||
|
required int userId,
|
||||||
|
required AutonomyLevel autonomyLevel,
|
||||||
|
RelationshipTier? relationshipTier,
|
||||||
|
}) async {
|
||||||
final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
|
final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
|
||||||
'autonomy_level': level.name,
|
'autonomy_level': autonomyLevel.name,
|
||||||
|
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||||
});
|
});
|
||||||
return TwinSettings.fromJson(json);
|
return TwinSettings.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -7,17 +7,27 @@ import '../db/app_database.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/push_token_service.dart';
|
import '../services/push_token_service.dart';
|
||||||
|
import '../services/snooze_controller.dart';
|
||||||
|
import '../services/snooze_notification_service.dart';
|
||||||
|
|
||||||
class SessionState extends ChangeNotifier {
|
class SessionState extends ChangeNotifier {
|
||||||
SessionState({ApiClient? api, AppDatabase? db})
|
SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler})
|
||||||
: _api = api ?? ApiClient(),
|
: _api = api ?? ApiClient(),
|
||||||
_db = db;
|
_db = db,
|
||||||
|
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
|
||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
AppDatabase? _db;
|
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;
|
User? user;
|
||||||
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
||||||
|
RelationshipTier relationshipTier = RelationshipTier.formal;
|
||||||
String? error;
|
String? error;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
bool toneOnboardingDone = false;
|
bool toneOnboardingDone = false;
|
||||||
|
|
@ -139,8 +149,9 @@ class SessionState extends ChangeNotifier {
|
||||||
Future<void> setAutonomy(AutonomyLevel level) async {
|
Future<void> setAutonomy(AutonomyLevel level) async {
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
try {
|
try {
|
||||||
final settings = await _api.patchTwinSettings(user!.id, level);
|
final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level);
|
||||||
autonomyLevel = settings.autonomyLevel;
|
autonomyLevel = settings.autonomyLevel;
|
||||||
|
relationshipTier = settings.relationshipTier;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '자율성 변경 실패 (${e.statusCode})';
|
error = '자율성 변경 실패 (${e.statusCode})';
|
||||||
|
|
@ -148,6 +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
|
/// Registers a real FCM token when Firebase is configured; otherwise a stable
|
||||||
/// `install:` placeholder (server skips placeholders for delivery).
|
/// `install:` placeholder (server skips placeholders for delivery).
|
||||||
Future<void> _registerDeviceTokenBestEffort() async {
|
Future<void> _registerDeviceTokenBestEffort() async {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.14"
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -286,6 +294,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -552,6 +592,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -773,6 +821,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -845,6 +901,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -855,4 +919,4 @@ packages:
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0-0 <4.0.0"
|
dart: ">=3.10.0-0 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.32.0"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ dependencies:
|
||||||
google_fonts: ^6.3.2
|
google_fonts: ^6.3.2
|
||||||
firebase_core: ^4.12.1
|
firebase_core: ^4.12.1
|
||||||
firebase_messaging: ^16.4.3
|
firebase_messaging: ^16.4.3
|
||||||
|
flutter_local_notifications: ^20.1.0
|
||||||
|
timezone: ^0.10.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,34 @@ void main() {
|
||||||
await db.replaceToneSamples(['하나만']);
|
await db.replaceToneSamples(['하나만']);
|
||||||
expect(await db.loadToneSamples(), ['하나만']);
|
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});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,26 @@ void main() {
|
||||||
expect(c.id, 3);
|
expect(c.id, 3);
|
||||||
expect(c.userIds, [1, 2]);
|
expect(c.userIds, [1, 2]);
|
||||||
expect(c.twinDisabledByPeer, isTrue);
|
expect(c.twinDisabledByPeer, isTrue);
|
||||||
|
expect(c.twinDisabledByFlood, isFalse);
|
||||||
expect(c.titleFor(1), '대화 · 상대 #2');
|
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', () {
|
test('EscalationLogEntry and Contact parse', () {
|
||||||
final log = EscalationLogEntry.fromJson({
|
final log = EscalationLogEntry.fromJson({
|
||||||
'id': 9,
|
'id': 9,
|
||||||
|
|
@ -33,5 +50,28 @@ void main() {
|
||||||
'relationship_note': '대학',
|
'relationship_note': '대학',
|
||||||
});
|
});
|
||||||
expect(contact.contactUserId, 7);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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, '내일');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue