Compare commits

..

2 Commits

Author SHA1 Message Date
Cursor Agent 3ca2c2bea9
fix(ui): drop unused BrandMark AppTheme import
Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-08-03 04:28:58 +00:00
Cursor Agent d905d0e5e8
feat(ui): iMessage-inspired light default + soft charcoal dark
Default ThemeMode.light with white messenger stage, blue mine bubbles,
and gray peer bubbles. Dark theme uses elevated charcoal instead of
pure black; composer and CTAs follow common messenger patterns.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-08-03 04:28:41 +00:00
17 changed files with 68 additions and 778 deletions

View File

@ -7,7 +7,6 @@ from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples
from .summarize import summarize_messages
@asynccontextmanager
@ -73,22 +72,3 @@ def draft(req: DraftRequest):
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
return DraftResponse(status=status, text=text)
class SummarizeRequest(BaseModel):
my_display_name: str
context_lines: List[str]
model: str = "gemini-2.5-flash"
class SummarizeResponse(BaseModel):
status: str
summary: str
@app.post("/summarize", response_model=SummarizeResponse)
def summarize(req: SummarizeRequest):
"""단톡 따라잡기 (roadmap.md §2.7-A) -- read-only catch-up summary, no
escalation/identity gating since nothing generated here is ever sent."""
status, text = summarize_messages(req.my_display_name, req.context_lines, model=req.model)
return SummarizeResponse(status=status, summary=text)

View File

@ -1,46 +0,0 @@
"""Group catch-up summary -- roadmap.md §2.7-A "단톡 따라잡기". Separate from
generation.py's draft_reply: this never produces something meant to be sent,
so it carries none of the escalation/identity gating a draft needs, and it
summarizes many messages at once instead of drafting one reply.
"""
from .generation import load_dotenv_if_present # re-exported for main.py convenience
SUMMARY_SYSTEM_PROMPT = """너는 사용자가 오랫동안 못 본 단체 대화방을 대신 읽고 요약해 주는 비서다. \
아래 대화 로그를 보고, 사용자('')에게 필요한 내용만 3~5줄로 한국어로 요약해라.
지켜야 :
- 나에게 멘션/질문된 , 결정된 사항(약속·시간·장소 ) 우선으로 다룬다.
- 나와 무관한 잡담은 요약에서 뺀다.
- 답장이 필요해 보이는 항목이 있으면 마지막 줄에 "-> 답장 필요: ..." 형식으로 짧게 짚어준다.
- 요약문만 출력한다. 인사말, 설명, 따옴표를 덧붙이지 않는다.
- 요약은 자체로 전송되지 않는다 -- 답장은 항상 사용자가 직접 검토해서 보낸다."""
def build_summary_prompt(my_display_name, context_lines):
context = "\n".join(context_lines)
return f"""[내 이름] {my_display_name}\n\n[안 본 동안의 대화]\n{context}\n\n위 대화 요약:"""
def summarize_messages(my_display_name, context_lines, model="gemini-2.5-flash", api_key=None):
"""Returns (status, text). status is one of "no_key" | "ok".
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated summary.
"""
import os
api_key = api_key or os.environ.get("GEMINI_API_KEY")
prompt = build_summary_prompt(my_display_name, context_lines)
if not api_key:
return "no_key", prompt
from google import genai # pip install google-genai
from google.genai import types
client = genai.Client(api_key=api_key)
resp = client.models.generate_content(
model=model,
contents=prompt,
config=types.GenerateContentConfig(system_instruction=SUMMARY_SYSTEM_PROMPT, max_output_tokens=300),
)
return "ok", resp.text.strip()

View File

@ -90,18 +90,3 @@ def test_draft_rejects_both_style_sources():
def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422
def test_summarize_no_key(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
resp = client.post(
"/summarize",
json={
"my_display_name": "민수",
"context_lines": ["철수: 토요일 모임 3시로 하자", "영희: ㅇㅋ"],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "no_key"
assert "토요일 모임 3시로 하자" in body["summary"]

View File

@ -1,55 +0,0 @@
"""단톡 따라잡기 (roadmap.md §2.7-A) -- the Gemini call is mocked, same pattern
as test_generation.py. Asserts the no_key fallback and the exact request
shape sent to the model; not real summary quality."""
import sys
import types
from unittest.mock import MagicMock
from app.summarize import build_summary_prompt, summarize_messages
def test_build_summary_prompt_includes_name_and_context():
prompt = build_summary_prompt("민수", ["철수: 이번 주 토요일 모임 3시로 확정", "영희: 나 못 갈 것 같아"])
assert "민수" in prompt
assert "토요일 모임 3시로 확정" in prompt
assert "나 못 갈 것 같아" in prompt
def test_summarize_messages_no_key_returns_prompt_without_calling_model(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = summarize_messages("민수", ["철수: 밥 언제 먹지"], api_key=None)
assert status == "no_key"
assert "밥 언제 먹지" in text
def test_summarize_messages_ok_calls_gemini_with_expected_args(monkeypatch):
# summarize.py does `from google import genai` / `from google.genai import
# types` *inside* summarize_messages, same stubbing approach as
# test_generation.py to avoid the real google-genai dependency chain.
fake_response = MagicMock()
fake_response.text = " 토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기 "
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 = summarize_messages(
"민수", ["철수: 토요일 3시에 모이자", "영희: ㅇㅋ"], model="gemini-2.5-flash", api_key="fake-key"
)
assert status == "ok"
assert text == "토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기"
fake_genai.Client.assert_called_once_with(api_key="fake-key")
fake_client.models.generate_content.assert_called_once()
_, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash"
assert "토요일 3시에 모이자" in kwargs["contents"]

View File

@ -193,25 +193,12 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
for _, pp := range pps {
participantIDs = append(participantIDs, pp.UserID)
}
// Unread count (roadmap.md §2.7-A "안 본 동안" badge) -- messages
// after this participant's read marker, excluding their own sends
// (sending a message means you've obviously seen it).
unreadQuery := db.Model(&Message{}).
Where("conversation_id = ? AND retracted = ? AND sender_id != ?", conv.ID, false, userID)
if p.LastReadMessageID != nil {
unreadQuery = unreadQuery.Where("id > ?", *p.LastReadMessageID)
}
var unreadCount int64
unreadQuery.Count(&unreadCount)
out = append(out, gin.H{
"id": conv.ID,
"is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
"user_ids": participantIDs,
"created_at": conv.CreatedAt,
"unread_count": unreadCount,
})
}
c.JSON(http.StatusOK, gin.H{"conversations": out})

View File

@ -53,42 +53,6 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
return &out, nil
}
type summaryRequest struct {
MyDisplayName string `json:"my_display_name"`
ContextLines []string `json:"context_lines"`
}
type summaryResponse struct {
Status string `json:"status"`
Summary string `json:"summary"`
}
// requestSummary calls ai-service's POST /summarize -- the "단톡 따라잡기"
// catch-up summary (roadmap.md §2.7-A), separate from /draft since it never
// produces something meant to be sent, only read.
func (c *AIServiceClient) requestSummary(req summaryRequest) (*summaryResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/summarize", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out summaryResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
type escalationCheckRequest struct {
Text string `json:"text"`
}

View File

@ -1,135 +0,0 @@
package main
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// registerGroupSummaryRoutes implements roadmap.md §2.7-A "단톡 따라잡기":
// a read marker per participant + an AI-generated catch-up summary of
// whatever arrived since that marker. Kept separate from a1_a2_routes.go
// since it's new (2026-07-31) scope, not part of the original A1/A2 surface.
func registerGroupSummaryRoutes(r *gin.Engine, db *gorm.DB, ai *AIServiceClient) {
// POST /conversations/:id/read advances the caller's read marker. Body:
// {"message_id": <highest message id the client has seen>}. Never moves
// the marker backward, so out-of-order client calls can't lose history.
r.POST("/conversations/:id/read", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var req struct {
MessageID uint `json:"message_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
if participant.LastReadMessageID == nil || *participant.LastReadMessageID < req.MessageID {
participant.LastReadMessageID = &req.MessageID
if err := db.Save(&participant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"last_read_message_id": participant.LastReadMessageID})
})
// GET /conversations/:id/summary returns an AI-generated 3~5 line
// catch-up of messages since the caller's read marker, focused on
// mentions of the caller and decisions made (PRD.md §2.3-②). Read-only
// -- it never sends anything, so it carries none of the escalation/
// autonomy-level machinery that POST /messages does.
r.GET("/conversations/:id/summary", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
q := db.Where("conversation_id = ? AND retracted = ?", convID, false)
if participant.LastReadMessageID != nil {
q = q.Where("id > ?", *participant.LastReadMessageID)
}
var unread []Message
q.Order("id asc").Find(&unread)
if len(unread) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "empty", "summary": "", "unread_count": 0})
return
}
names := map[uint]string{}
contextLines := make([]string, 0, len(unread))
needsReply := false
for _, m := range unread {
var label string
if m.SenderID == actor.ID {
label = "나"
} else {
name, cached := names[m.SenderID]
if !cached {
var sender User
if err := db.First(&sender, m.SenderID).Error; err == nil {
name = sender.DisplayName
}
if name == "" {
name = "상대"
}
names[m.SenderID] = name
}
label = name
needsReply = true
}
contextLines = append(contextLines, label+": "+m.Text)
}
result, err := ai.requestSummary(summaryRequest{
MyDisplayName: actor.DisplayName,
ContextLines: contextLines,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": result.Status,
"summary": strings.TrimSpace(result.Summary),
"unread_count": len(unread),
"needs_reply": needsReply,
})
})
}

View File

@ -1,163 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func createGroup(t *testing.T, serverURL, token string, userIDs []uint) uint {
t.Helper()
resp := postJSONAuth(t, serverURL+"/conversations", token, createConversationRequest{
UserIDs: userIDs,
IsGroup: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("create group: %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return uint(out["id"].(float64))
}
func TestGroupConversationBlocksTwinAutoSendRegardlessOfAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
peerID, _ := mustSignup(t, server.URL, "친구")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
// L2 with a matching whitelist rule would normally auto-send in a 1:1
// conversation (main_test.go's autonomy flow tests cover that) -- group
// conversations must block twin sends outright regardless (PRD.md
// §2.3-③, roadmap.md §2.7-A: "이 시나리오는 L0 고정, 자동 발송 없음").
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2)
resp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "ㅇㅋ 알겠음",
SenderMode: SenderTwin,
Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 blocking twin send in group, got %d", resp.StatusCode)
}
// A human-authored send in the same group is unaffected.
humanResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "내가 직접 씀",
})
if humanResp.StatusCode != http.StatusOK {
t.Fatalf("expected human send to succeed, got %d", humanResp.StatusCode)
}
}
func TestGroupSummaryReflectsUnreadSinceReadMarker(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
convIDStr := strconv.FormatUint(uint64(convID), 10)
// Nothing read yet -- summary should be empty (no messages at all).
emptyResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
emptyResp.Header.Set("Authorization", "Bearer "+ownerToken)
emptyOut, err := http.DefaultClient.Do(emptyResp)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var emptyBody map[string]interface{}
json.NewDecoder(emptyOut.Body).Decode(&emptyBody)
if emptyBody["status"] != "empty" {
t.Fatalf("expected empty status with no messages, got %v", emptyBody)
}
// Peer sends two messages the owner hasn't read.
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "토요일 모임 3시 어때?",
})
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "민수야 답장 좀",
})
sumReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
sumReq.Header.Set("Authorization", "Bearer "+ownerToken)
sumResp, err := http.DefaultClient.Do(sumReq)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var sumBody map[string]interface{}
json.NewDecoder(sumResp.Body).Decode(&sumBody)
if sumBody["status"] != "ok" {
t.Fatalf("expected ok status, got %v", sumBody)
}
if int(sumBody["unread_count"].(float64)) != 2 {
t.Fatalf("expected 2 unread, got %v", sumBody["unread_count"])
}
if sumBody["needs_reply"] != true {
t.Fatalf("expected needs_reply true, got %v", sumBody["needs_reply"])
}
summaryText := sumBody["summary"].(string)
if !containsAll(summaryText, "토요일 모임 3시 어때", "민수야 답장 좀") {
t.Fatalf("summary missing unread content: %v", summaryText)
}
// List conversations should report the same unread count.
listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil)
listReq.Header.Set("Authorization", "Bearer "+ownerToken)
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("list conversations: %v", err)
}
var listBody map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&listBody)
convs := listBody["conversations"].([]interface{})
found := false
for _, raw := range convs {
c := raw.(map[string]interface{})
if uint(c["id"].(float64)) == convID {
found = true
if int(c["unread_count"].(float64)) != 2 {
t.Fatalf("expected list unread_count 2, got %v", c["unread_count"])
}
}
}
if !found {
t.Fatalf("conversation %d not found in list", convID)
}
// Mark read up to the latest message, then re-fetch: should be empty
// again and the list's unread_count should drop to 0.
var msgs map[string]interface{}
msgsReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/messages", nil)
msgsReq.Header.Set("Authorization", "Bearer "+ownerToken)
msgsResp, _ := http.DefaultClient.Do(msgsReq)
json.NewDecoder(msgsResp.Body).Decode(&msgs)
msgList := msgs["messages"].([]interface{})
lastID := uint(msgList[len(msgList)-1].(map[string]interface{})["id"].(float64))
readResp := postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/read", ownerToken, map[string]interface{}{"message_id": lastID})
if readResp.StatusCode != http.StatusOK {
t.Fatalf("mark read: %d", readResp.StatusCode)
}
afterReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
afterReq.Header.Set("Authorization", "Bearer "+ownerToken)
afterResp, _ := http.DefaultClient.Do(afterReq)
var afterBody map[string]interface{}
json.NewDecoder(afterResp.Body).Decode(&afterBody)
if afterBody["status"] != "empty" {
t.Fatalf("expected empty summary after marking read, got %v", afterBody)
}
}
func containsAll(haystack string, needles ...string) bool {
for _, n := range needles {
if !strings.Contains(haystack, n) {
return false
}
}
return true
}

View File

@ -84,7 +84,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
registerBRoutes(r, db)
registerInviteOpsRoutes(r, db)
registerDemoRoutes(r)
registerGroupSummaryRoutes(r, db, ai)
r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) {
@ -263,16 +262,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return
}
// 단톡 따라잡기(PRD.md §2.3-③, roadmap.md §2.7-A): 이 시나리오는
// L0 고정, 자동 발송 없음 -- 사용자의 전역 자율성 레벨(L1/L2)과
// 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은
// 항상 사람이 검토해서 직접 보낸다.
if conversation.IsGroup {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return
}
result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err)
if err != nil {

View File

@ -50,17 +50,6 @@ func mockAIService(t *testing.T) *httptest.Server {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
case "/summarize":
var req summaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summaryResponse{
Status: "ok",
Summary: "mock summary for " + req.MyDisplayName + ": " + strings.Join(req.ContextLines, " | "),
})
default:
w.WriteHeader(http.StatusNotFound)
}

View File

@ -74,10 +74,6 @@ type ConversationParticipant struct {
ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
// LastReadMessageID is this participant's read marker (roadmap.md
// §2.7-A "단톡 따라잡기") -- nil means nothing read yet. Drives the
// unread badge and GET /conversations/:id/summary's "안 본 동안" window.
LastReadMessageID *uint
}
type Message struct {

View File

@ -38,11 +38,10 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- UI: **iMessage-inspired light default** + soft charcoal dark —
기본 `ThemeMode.light`, 내 버블 `#007AFF`, 다크 캔버스 `#141418` (배포 진행)
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03) — 그룹 생성 UI,
안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커, 안 본 배지,
그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지).
- UI: **iMessage-inspired light default** + soft charcoal dark (진행 중 배포) —
기본 `ThemeMode.light`, 내 버블 `#007AFF`, 다크 캔버스 `#141418`
- **발견(2026-07-31): Track C 콘텐츠 갭**`PRD.md` P0 대비 단톡 따라잡기·관계별 페르소나·
스팸 감지 미구현. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 지금 바로 코드 착수 가능
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서
@ -180,11 +179,11 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
| **N4-C1b** | `GET /conversations/:id/summary` | **done** (2026-08-03) | `core-backend/group_summary.go` + 읽음 마커(`POST /conversations/:id/read`) |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | todo | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
| **N4-C1b** | `GET /conversations/:id/summary` | todo | 마지막 읽음 이후 메시지 모아 AI 서비스로 전달하는 core-backend 라우트 |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | todo | `ai-service/app/` 신규 모듈, `generate_draft.py` 패턴 재사용 |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | todo | `chat_screen.dart`. 자동발송 없음 — 안전 불변식 유지 |
| **N4-C1e** | "안 본 동안" 배지 | todo | 대화 목록에 마지막 읽음 이후 새 메시지 수 표시, read-marker 필요 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 |
@ -249,9 +248,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음:
**Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)**
N4-4 스모크 → Android UI QA (N4-5~10)
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Track C 콘텐츠 갭(N4-C1→C2→C3, 지금 착수 가능)**
병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -133,24 +133,18 @@
단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼;
페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음).
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
동적으로 상대 필드를 추가/삭제할 수 있게 됨. 2명 이상이면 자동으로 `is_group:true`로 생성
- [x] `GET /conversations/:id/summary``core-backend/group_summary.go`. 참가자별 읽음 마커
(`ConversationParticipant.LastReadMessageID`)를 두고, 그 이후 메시지만 모아 AI 서비스로 전달.
`POST /conversations/:id/read`로 마커 갱신. `GET /conversations`에도 `unread_count` 추가
- [x] 요약 생성 — `ai-service/app/summarize.py`(신규), "나에게 멘션된 것/결정된 사항" 위주 3~5줄
+ 답장 필요 항목 표시(`PRD.md` §2.3-②). `POST /summarize` 엔드포인트. 읽기 전용이라 escalation/
identity 게이트 없음(아무것도 발송되지 않으므로)
- [x] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`의 안 본 동안 요약 다이얼로그에서
바로 `_requestDraft()`로 이어감. **그룹 대화는 전역 자율성 레벨과 무관하게 서버(`main.go`)가
트윈 발송 자체를 항상 차단** — 클라이언트도 L0 취급으로 렌더링(PRD §2.3-③, 안전 불변식 유지)
- [x] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart`, 서버가
계산한 `unread_count`를 그대로 표시
- **주의**: 읽음 마커는 채팅방에 "들어올 때"가 아니라 "나갈 때"(`dispose()`) 찍는다 — 들어오는
순간 찍으면 그 방을 여는 즉시 안 본 게 0이 되어 "안 본 동안 요약"이 항상 빈 결과만 보여주는
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3, 현재 미구현 — `isGroup`은 아이콘 표시용 플래그뿐, 요약
로직·엔드포인트·UI 전부 없음)
- [ ] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
상대 1명만 받음. 서버(`is_group`+복수 `user_ids`)는 이미 지원하므로 클라이언트만 필요
- [ ] `GET /conversations/:id/summary` — 마지막 읽음 시점 이후 메시지를 모아 AI 서비스로 전달 —
`core-backend`(신규 라우트)
- [ ] 요약 생성 — "나에게 멘션된 것/결정된 사항" 위주 3~5줄(`PRD.md` §2.3-②) — `ai-service/app/`
신규 모듈, `generate_draft.py`의 Gemini 호출 패턴 재사용
- [ ] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`. **이 시나리오는 L0 고정,
자동발송 없음**(PRD §2.3-③) — 안전 불변식 그대로 유지, 우회 경로 만들지 않기
- [ ] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart` +
서버 read-marker 필요
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재
`TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음)

View File

@ -40,7 +40,6 @@ class ConversationSummary {
required this.isGroup,
required this.userIds,
required this.twinDisabledByPeer,
this.unreadCount = 0,
this.createdAt,
});
@ -48,7 +47,6 @@ class ConversationSummary {
final bool isGroup;
final List<int> userIds;
final bool twinDisabledByPeer;
final int unreadCount;
final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
@ -58,7 +56,6 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
);
}
@ -169,30 +166,6 @@ class WhitelistRule {
);
}
/// (roadmap.md §2.7-A) GET /conversations/:id/summary result.
class GroupSummaryResult {
GroupSummaryResult({
required this.status,
required this.summary,
required this.unreadCount,
required this.needsReply,
});
final String status; // empty | ok
final String summary;
final int unreadCount;
final bool needsReply;
factory GroupSummaryResult.fromJson(Map<String, dynamic> json) => GroupSummaryResult(
status: json['status'] as String? ?? 'empty',
summary: json['summary'] as String? ?? '',
unreadCount: json['unread_count'] as int? ?? 0,
needsReply: json['needs_reply'] as bool? ?? false,
);
bool get isEmpty => status == 'empty' || unreadCount == 0;
}
class DraftResult {
DraftResult({required this.status, required this.text});

View File

@ -11,11 +11,10 @@ import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.conversationId, this.title, this.isGroup = false});
const ChatScreen({super.key, required this.conversationId, this.title});
final int conversationId;
final String? title;
final bool isGroup;
@override
State<ChatScreen> createState() => _ChatScreenState();
@ -32,12 +31,10 @@ class _ChatScreenState extends State<ChatScreen> {
DraftResult? _pendingDraft;
bool _busy = false;
bool _loadingHistory = true;
late final ApiClient _api;
@override
void initState() {
super.initState();
_api = context.read<SessionState>().api;
_socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent);
_loadHistory();
@ -55,11 +52,6 @@ class _ChatScreenState extends State<ChatScreen> {
_loadingHistory = false;
});
_scrollToEnd();
// "나갈 때"(dispose) --
// 0
// "안 본 동안 요약" (roadmap.md
// §2.7-A).
// (_onEvent ).
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
@ -69,17 +61,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
/// (roadmap.md §2.7-A) .
Future<void> _markLatestRead() async {
if (_messages.isEmpty) return;
final latestId = _messages.map((m) => m.id).reduce((a, b) => a > b ? a : b);
try {
await _api.markRead(widget.conversationId, latestId);
} on ApiException {
// Best-effort an unread badge staying stale isn't worth surfacing an error for.
}
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return;
@ -116,15 +97,10 @@ class _ChatScreenState extends State<ChatScreen> {
if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg));
_scrollToEnd();
_markLatestRead();
}
@override
void dispose() {
// _loadHistory() .
// Fire-and-forget: dispose는 ,
// .
_markLatestRead();
_sub?.cancel();
_socket?.dispose();
_input.dispose();
@ -200,17 +176,13 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _draftEdit.text.trim();
if (text.isEmpty) return;
// L0: twin send is forbidden server-side move text to human composer
// instead. L0
// (PRD.md §2.3-, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음").
if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) {
// L0: twin send is forbidden server-side move text to human composer instead.
if (session.autonomyLevel == AutonomyLevel.L0) {
setState(() {
_input.text = text;
_pendingDraft = null;
_draftEdit.clear();
_banner = widget.isGroup
? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.'
: 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
});
return;
}
@ -276,57 +248,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
/// (roadmap.md §2.7-A): 3~5
/// .
/// ( L0 ).
Future<void> _openSummary() async {
final session = context.read<SessionState>();
GroupSummaryResult? result;
String? error;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
if (result == null && error == null) {
session.api.getGroupSummary(widget.conversationId).then((r) {
setDialogState(() => result = r);
}).catchError((e) {
setDialogState(() => error = e is ApiException ? '요약 실패 (${e.statusCode})' : '요약 실패');
});
}
final r = result;
return AlertDialog(
title: const Text('안 본 동안 요약'),
content: SizedBox(
width: 320,
child: error != null
? Text(error!, style: TextStyle(color: Theme.of(ctx).colorScheme.error))
: r == null
? const SizedBox(
height: 60,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
: r.isEmpty
? const Text('안 본 메시지가 없습니다.')
: Text(r.summary.isEmpty ? '요약할 내용이 없습니다.' : r.summary),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')),
if (r != null && !r.isEmpty && r.needsReply)
FilledButton(
onPressed: () {
Navigator.pop(ctx);
_requestDraft();
},
child: const Text('초안 요청'),
),
],
);
},
),
);
}
Widget _buildL1Panel(BuildContext context) {
final theme = Theme.of(context);
final draft = _pendingDraft;
@ -370,7 +291,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
final level = context.watch<SessionState>().autonomyLevel;
final isL0 = level == AutonomyLevel.L0 || widget.isGroup;
final isL0 = level == AutonomyLevel.L0;
final title = isL0
? '초안 (L0) — 직접 보내기'
: level == AutonomyLevel.L1
@ -398,9 +319,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (isL0) ...[
const SizedBox(height: 6),
Text(
widget.isGroup
? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.'
: 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
@ -443,12 +362,6 @@ class _ChatScreenState extends State<ChatScreen> {
appBar: AppBar(
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [
if (widget.isGroup)
IconButton(
tooltip: '안 본 동안 요약',
onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined),
),
IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto,

View File

@ -83,18 +83,14 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
}
Future<void> _createConversation() async {
final peerCtrl = TextEditingController();
final session = context.read<SessionState>();
final myId = session.user?.id;
// (roadmap.md §2.7-A "단톡 따라잡기"):
// . 1( 1) 1:1 .
final peerCtrls = <TextEditingController>[TextEditingController()];
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
builder: (ctx) => AlertDialog(
title: const Text('새 대화'),
content: SingleChildScrollView(
child: Column(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -102,97 +98,52 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
MyUserIdChip(userId: myId),
const SizedBox(height: 12),
Text(
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. '
'2명 이상 넣으면 그룹 대화로 만들어집니다.',
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
],
for (var i = 0; i < peerCtrls.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: peerCtrls[i],
TextField(
controller: peerCtrl,
keyboardType: TextInputType.number,
autofocus: i == 0,
decoration: InputDecoration(
labelText: i == 0 ? '상대 사용자 ID (숫자)' : '상대 ${i + 1} 사용자 ID',
helperText: i == 0 ? '이름/닉네임이 아니라 숫자 ID입니다.' : null,
),
),
),
if (peerCtrls.length > 1)
IconButton(
tooltip: '삭제',
onPressed: () => setDialogState(() => peerCtrls.removeAt(i)),
icon: const Icon(Icons.close, size: 18),
),
],
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setDialogState(() => peerCtrls.add(TextEditingController())),
icon: const Icon(Icons.add, size: 18),
label: const Text('상대 추가 (그룹으로)'),
autofocus: true,
decoration: const InputDecoration(
labelText: '상대 사용자 ID (숫자)',
helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
],
),
),
);
if (ok != true || !mounted) return;
final me = session.user;
final peer = int.tryParse(peerCtrl.text.trim());
if (me == null) return;
final peers = <int>[];
for (final ctrl in peerCtrls) {
final text = ctrl.text.trim();
if (text.isEmpty) continue;
final id = int.tryParse(text);
if (id == null) {
if (peer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
);
return;
}
if (id == me.id) {
if (peer == me.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
);
return;
}
if (!peers.contains(id)) peers.add(id);
}
if (peers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')),
);
return;
}
final isGroup = peers.length > 1;
try {
final conv = await session.api.createConversation(
userIds: [me.id, ...peers],
isGroup: isGroup,
);
final conv = await session.api.createConversation(userIds: [me.id, peer]);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conv.id,
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
isGroup: isGroup,
title: _peerNames[peer] ?? '상대 #$peer',
),
),
);
@ -341,7 +292,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
builder: (_) => ChatScreen(
conversationId: room.id,
title: _titleFor(room, me),
isGroup: room.isGroup,
),
),
);
@ -393,23 +343,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
],
),
),
if (room.unreadCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount > 99 ? '99+' : '${room.unreadCount}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
],
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
],
),

View File

@ -185,18 +185,6 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto');
}
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {
'message_id': messageId,
});
}
Future<GroupSummaryResult> getGroupSummary(int conversationId) async {
final json = await _getObject('/conversations/$conversationId/summary');
return GroupSummaryResult.fromJson(json);
}
Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract');
}