Compare commits
2 Commits
ee8a41de17
...
3ca2c2bea9
| Author | SHA1 | Date |
|---|---|---|
|
|
3ca2c2bea9 | |
|
|
d905d0e5e8 |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -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`만 있고 페르소나 필드 없음)
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue