From c35fad2737c206935935a674717f0c183285be00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:39:55 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=97=B0=EC=8A=A4=EB=9F=AC=EC=9B=80?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EA=B3=84=EC=B8=A1(N4-12)=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84:=20=EC=B4=88=EC=95=88=20=EB=AC=B4=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=9C=EC=86=A1=EB=A5=A0=20+=20=EC=9B=90?= =?UTF-8?q?=ED=83=AD=20=EB=82=98=EB=8B=B5=EC=95=84=EC=9A=94=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 암묵 신호: Message.DraftEdited(nullable)를 트윈 승인-발송 경로에서 클라이언트가 보낸 original_draft_text와 실제 발송 텍스트를 diff해 계산(사람 메시지·구버전 클라는 nil, 추측하지 않음). 명시 신호: Message.NaturalnessRating(nullable) + POST /messages/:id/feedback(트윈 메시지만, 재제출은 덮어씀)로 "이 답장 나답아요?" 원탭 👍/👎을 채팅방에 비침투적으로(모달 아님, 한 번 탭하면 다시 안 보임) 노출. /admin/metrics에 draft_unedited_rate· naturalness_positive_rate 추가(분모 0 zero-guard), 대시보드 카드 2개 추가. PoC 실행이나 결론이 아니라 캡처 장치일 뿐 — 실제 자연스러움 %는 N5 이후 실 베타 데이터로 확정한다. docs/roadmap.md·deploy-checklist.md N4-12 동기화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m --- core-backend/a1_a2_routes.go | 10 +- core-backend/b_routes.go | 18 +- core-backend/main.go | 102 +++++++++ core-backend/main_test.go | 298 +++++++++++++++++++++++++ core-backend/models.go | 17 +- docs/deploy-checklist.md | 2 +- docs/roadmap.md | 9 +- mobile/lib/models/models.dart | 13 ++ mobile/lib/screens/chat_screen.dart | 44 +++- mobile/lib/services/api_client.dart | 16 ++ mobile/lib/services/message_sync.dart | 9 + mobile/lib/widgets/message_bubble.dart | 50 +++++ mobile/test/message_sync_test.dart | 26 ++- mobile/test/models_test.dart | 33 +++ 14 files changed, 624 insertions(+), 23 deletions(-) diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index 6ca0ff4..ff0cb06 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -257,15 +257,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { query.Order("id asc").Find(&messages) out := make([]gin.H, 0, len(messages)) for _, m := range messages { - out = append(out, gin.H{ - "id": m.ID, - "conversation_id": m.ConversationID, - "sender_id": m.SenderID, - "sender_mode": m.SenderMode, - "text": m.Text, - "retracted": m.Retracted, - "created_at": m.CreatedAt, - }) + out = append(out, messageJSON(m)) } c.JSON(http.StatusOK, gin.H{"messages": out}) }) diff --git a/core-backend/b_routes.go b/core-backend/b_routes.go index c8d409c..5a97fdd 100644 --- a/core-backend/b_routes.go +++ b/core-backend/b_routes.go @@ -15,13 +15,15 @@ type registerDeviceRequest struct { func messageJSON(m Message) gin.H { return gin.H{ - "id": m.ID, - "conversation_id": m.ConversationID, - "sender_id": m.SenderID, - "sender_mode": m.SenderMode, - "text": m.Text, - "retracted": m.Retracted, - "created_at": m.CreatedAt, + "id": m.ID, + "conversation_id": m.ConversationID, + "sender_id": m.SenderID, + "sender_mode": m.SenderMode, + "text": m.Text, + "retracted": m.Retracted, + "draft_edited": m.DraftEdited, + "naturalness_rating": m.NaturalnessRating, + "created_at": m.CreatedAt, } } @@ -251,6 +253,8 @@ const adminDashboardHTML = ` ['twin msgs', data.messages_twin_total], ['escalations', data.escalations_total], ['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)], + ['초안 무수정 발송률', (data.draft_unedited_rate || 0).toFixed(3) + ' (n=' + (data.draft_edited_tracked_total || 0) + ')'], + ['자연스러움 긍정률', (data.naturalness_positive_rate || 0).toFixed(3) + ' (n=' + (data.naturalness_ratings_total || 0) + ')'], ['draft req', data.draft_requests], ['draft err rate', (data.draft_error_rate || 0).toFixed(3)], ['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)], diff --git a/core-backend/main.go b/core-backend/main.go index bb46181..7a71432 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -31,6 +31,20 @@ type sendMessageRequest struct { // L2 draft outside the whitelist (PRD.md §2.2). Ignored for // human-authored messages. Approved bool `json:"approved"` + // OriginalDraftText is the AI draft's text before any edits, sent by the + // client alongside the (possibly edited) final Text so the server can + // diff them into Message.DraftEdited (PRD.md §5 L1 approval-rate proxy, + // deploy-checklist.md N4-12). Optional -- omitted/empty means "not + // derived from a draft, or an older client", and DraftEdited stays nil + // rather than being guessed. Only consulted for SenderMode == twin. + OriginalDraftText string `json:"original_draft_text"` +} + +// messageFeedbackRequest is the explicit naturalness signal (vision.md +// metric, PRD.md §5, deploy-checklist.md N4-12) — "이 답장 나답아요?" one-tap +// thumbs up/down on a twin-authored message. +type messageFeedbackRequest struct { + Natural bool `json:"natural"` } type updateTwinSettingsRequest struct { @@ -125,6 +139,34 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) } + // L1 approval-rate proxy (PRD.md §5 "초안을 수정 없이 그대로 발송한 + // 비율", vision.md 자연스러움 지표, deploy-checklist.md N4-12): + // draft_edited is only set (non-nil) for twin-mode messages sent + // through the draft-approval flow with an original draft to diff + // against -- messages where it's nil (human-authored, or no draft + // text sent) are excluded from both counts so they can't dilute + // the rate either way. Zero-tracked-messages guarded the same way + // peer_veto_rate guards zero conversations above. + var draftTrackedTotal, draftEditedTrue int64 + db.Model(&Message{}).Where("sender_mode = ? AND draft_edited IS NOT NULL", SenderTwin).Count(&draftTrackedTotal) + db.Model(&Message{}).Where("sender_mode = ? AND draft_edited = ?", SenderTwin, true).Count(&draftEditedTrue) + var draftUneditedRate float64 + if draftTrackedTotal > 0 { + draftUneditedRate = float64(draftTrackedTotal-draftEditedTrue) / float64(draftTrackedTotal) + } + + // Explicit naturalness signal ("이 답장 나답아요?" 원탭, vision.md 지표, + // deploy-checklist.md N4-12): same nil-excluded-from-denominator + // shape as draft_unedited_rate above -- unrated messages don't + // count as either positive or negative. + var naturalnessTrackedTotal, naturalnessPositiveTrue int64 + db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating IS NOT NULL", SenderTwin).Count(&naturalnessTrackedTotal) + db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating = ?", SenderTwin, true).Count(&naturalnessPositiveTrue) + var naturalnessPositiveRate float64 + if naturalnessTrackedTotal > 0 { + naturalnessPositiveRate = float64(naturalnessPositiveTrue) / float64(naturalnessTrackedTotal) + } + rt := runtimeMetrics.snapshot() c.JSON(http.StatusOK, gin.H{ "users_total": usersTotal, @@ -139,6 +181,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // vision.md doesn't pin down the exact denominator, so treat // this as a first approximation, not the final definition. "peer_veto_rate": peerVetoRate, + // L1 approval-rate proxy / naturalness instrumentation (PRD.md §5, + // vision.md, deploy-checklist.md N4-12). This is capture + // infrastructure, not a PoC conclusion -- see docs/roadmap.md and + // docs/deploy-checklist.md N4-12 for that distinction. + "draft_edited_tracked_total": draftTrackedTotal, + "draft_unedited_rate": draftUneditedRate, + "naturalness_ratings_total": naturalnessTrackedTotal, + "naturalness_positive_rate": naturalnessPositiveRate, // Process-local draft/AI timings (roadmap B). Reset on restart. "draft_requests": rt.DraftRequests, "draft_errors": rt.DraftErrors, @@ -371,11 +421,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi } } + // Implicit L1-approval-rate signal (PRD.md §5, deploy-checklist.md + // N4-12): only meaningful for twin-mode sends where the client told + // us what the original draft said. Absent/empty original text + // (human messages, or a client that isn't draft-derived) leaves + // DraftEdited nil rather than guessing true/false. + var draftEdited *bool + if req.SenderMode == SenderTwin && req.OriginalDraftText != "" { + edited := req.Text != req.OriginalDraftText + draftEdited = &edited + } + message := Message{ ConversationID: convID, SenderID: req.SenderID, SenderMode: req.SenderMode, Text: req.Text, + DraftEdited: draftEdited, } if err := db.Create(&message).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) @@ -432,6 +494,46 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true}) }) + // "이 답장 나답아요?" 자연스러움 피드백(vision.md 지표, PRD.md §5, + // deploy-checklist.md N4-12): 트윈이 실제로 보낸 메시지 위에 붙는 가벼운 + // 원탭 피드백 -- 사람이 직접 쓴 메시지는 이 지표의 대상이 아니라 400. + // 재제출은 덮어쓰기로 처리한다(409로 막지 않음): 클라이언트가 이미 한 + // 번 탭한 메시지는 다시 UI를 보여주지 않아 재제출 자체가 드물고, 네트워크 + // 재시도 등으로 같은 요청이 두 번 가더라도 "평가를 덮어쓴다"는 동작이 + // 사용자에게 해롭지 않기 때문 -- retract의 409(한 번 되돌린 걸 다시 + // 되돌릴 수 없음, 되돌리기는 상태를 한 방향으로만 바꿈)와는 성격이 다름. + r.POST("/messages/:id/feedback", func(c *gin.Context) { + msgID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var message Message + if err := db.First(&message, msgID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"}) + return + } + if message.SenderMode != SenderTwin { + c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored messages can be rated for naturalness"}) + return + } + + var req messageFeedbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + natural := req.Natural + message.NaturalnessRating = &natural + if err := db.Save(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, messageJSON(message)) + }) + r.POST("/conversations/:id/draft", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 6a845eb..2cd8d45 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -937,6 +937,304 @@ func TestRetractMissingMessage(t *testing.T) { } } +// --- Draft-edited implicit signal (PRD.md §5, deploy-checklist.md N4-12) --- + +func TestDraftEditedTrueWhenSentTextDiffersFromOriginalDraft(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) + + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 좋아 이따 보자", SenderMode: SenderTwin, Approved: true, + OriginalDraftText: "ㅇㅇ 좋아", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + msgID := uint(out["id"].(float64)) + + var message Message + db.First(&message, msgID) + if message.DraftEdited == nil || *message.DraftEdited != true { + t.Fatalf("expected DraftEdited=true, got %v", message.DraftEdited) + } +} + +func TestDraftEditedFalseWhenSentTextMatchesOriginalDraft(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) + + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true, + OriginalDraftText: "ㅇㅇ 좋아", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + msgID := uint(out["id"].(float64)) + + var message Message + db.First(&message, msgID) + if message.DraftEdited == nil || *message.DraftEdited != false { + t.Fatalf("expected DraftEdited=false, got %v", message.DraftEdited) + } +} + +func TestDraftEditedNilWhenNoOriginalDraftTextSent(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) + + // No OriginalDraftText field at all (e.g. an older client) -- must not + // be guessed as edited or unedited, stays nil. + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 알겠음", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + msgID := uint(out["id"].(float64)) + if _, present := out["draft_edited"]; !present || out["draft_edited"] != nil { + t.Fatalf("expected draft_edited to be null in response, got %v", out["draft_edited"]) + } + + var message Message + db.First(&message, msgID) + if message.DraftEdited != nil { + t.Fatalf("expected DraftEdited nil, got %v", *message.DraftEdited) + } +} + +func TestDraftEditedNotSetForHumanMessages(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) + + // Even if a caller weirdly sent original_draft_text on a human message, + // it must be ignored -- DraftEdited is a twin-only signal. + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "안녕", SenderMode: SenderHuman, + OriginalDraftText: "다른 텍스트", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + msgID := uint(out["id"].(float64)) + + var message Message + db.First(&message, msgID) + if message.DraftEdited != nil { + t.Fatalf("expected DraftEdited nil for a human message, got %v", *message.DraftEdited) + } +} + +// --- Explicit naturalness feedback ("이 답장 나답아요?", vision.md, N4-12) --- + +func TestMessageFeedbackOnTwinMessageReflectedOnRefetch(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) + } + if err := db.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: senderID}).Error; err != nil { + t.Fatalf("create participant: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true, + }) + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + feedbackResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true}) + if feedbackResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 submitting feedback, got %d", feedbackResp.StatusCode) + } + + // Re-fetch via the conversation history endpoint (authenticated) and + // confirm the rating stuck. + req, _ := http.NewRequest(http.MethodGet, convBase+"/messages", nil) + req.Header.Set("Authorization", "Bearer "+token) + listResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("list messages: %v", err) + } + var listOut map[string]interface{} + json.NewDecoder(listResp.Body).Decode(&listOut) + messages := listOut["messages"].([]interface{}) + var found map[string]interface{} + for _, m := range messages { + mm := m.(map[string]interface{}) + if uint(mm["id"].(float64)) == msgID { + found = mm + } + } + if found == nil { + t.Fatalf("sent message not found in refetch: %v", messages) + } + if found["naturalness_rating"] != true { + t.Fatalf("expected naturalness_rating=true on refetch, got %v", found["naturalness_rating"]) + } + + // Resubmission overwrites rather than 409ing (documented choice: a + // bounded rating change isn't unsafe the way re-retracting would be). + overwrite := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: false}) + if overwrite.StatusCode != http.StatusOK { + t.Fatalf("expected 200 overwriting feedback, got %d", overwrite.StatusCode) + } + var overwriteOut map[string]interface{} + json.NewDecoder(overwrite.Body).Decode(&overwriteOut) + if overwriteOut["naturalness_rating"] != false { + t.Fatalf("expected naturalness_rating=false after overwrite, got %v", overwriteOut["naturalness_rating"]) + } +} + +func TestMessageFeedbackRejectsHumanMessage(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) + } + sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "안녕", SenderMode: SenderHuman, + }) + var sent map[string]interface{} + json.NewDecoder(sendResp.Body).Decode(&sent) + msgID := uint(sent["id"].(float64)) + + resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 rating a human message, got %d", resp.StatusCode) + } +} + +func TestMessageFeedbackMissingMessage(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/messages/9999/feedback", messageFeedbackRequest{Natural: true}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +// --- /admin/metrics rate calculations for the two signals above --- + +func TestAdminMetricsDraftUneditedAndNaturalnessRates(t *testing.T) { + runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto + 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) + + // 2 edited + 3 unedited twin sends -> draft_unedited_rate should be + // 3/5 = 0.6. A plain human message and a twin send with no + // original_draft_text must not affect the denominator either way. + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "인간이 직접", SenderMode: SenderHuman}) + postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "초안없음", SenderMode: SenderTwin, Approved: true}) + for i := 0; i < 2; i++ { + postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "수정된 문구", SenderMode: SenderTwin, Approved: true, + OriginalDraftText: "원래 초안", + }) + } + for i := 0; i < 3; i++ { + postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "그대로", SenderMode: SenderTwin, Approved: true, + OriginalDraftText: "그대로", + }) + } + + mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil) + mreq.Header.Set("Authorization", "Bearer test-admin-token") + resp, err := http.DefaultClient.Do(mreq) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + + if out["draft_edited_tracked_total"].(float64) != 5 { + t.Fatalf("expected 5 draft-tracked messages, got %v", out["draft_edited_tracked_total"]) + } + if got := out["draft_unedited_rate"].(float64); got != 0.6 { + t.Fatalf("expected draft_unedited_rate 0.6, got %v", got) + } + + // No naturalness ratings submitted yet -- must not divide by zero. + if out["naturalness_ratings_total"].(float64) != 0 { + t.Fatalf("expected 0 naturalness ratings, got %v", out["naturalness_ratings_total"]) + } + if out["naturalness_positive_rate"].(float64) != 0 { + t.Fatalf("expected naturalness_positive_rate 0 (zero-guarded), got %v", out["naturalness_positive_rate"]) + } + + // Rate a couple of the twin messages we just sent and check the rate + // updates too (1 positive out of 2 rated -> 0.5). + var twinMessages []Message + db.Where("sender_mode = ?", SenderTwin).Order("id asc").Find(&twinMessages) + if len(twinMessages) < 2 { + t.Fatalf("expected at least 2 twin messages, got %d", len(twinMessages)) + } + postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[0].ID), 10)+"/feedback", messageFeedbackRequest{Natural: true}) + postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[1].ID), 10)+"/feedback", messageFeedbackRequest{Natural: false}) + + resp2, err := http.DefaultClient.Do(mreq) + if err != nil { + t.Fatalf("get metrics again: %v", err) + } + var out2 map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&out2) + if out2["naturalness_ratings_total"].(float64) != 2 { + t.Fatalf("expected 2 naturalness ratings, got %v", out2["naturalness_ratings_total"]) + } + if got := out2["naturalness_positive_rate"].(float64); got != 0.5 { + t.Fatalf("expected naturalness_positive_rate 0.5, got %v", got) + } +} + func TestDraftMissingConversation(t *testing.T) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ diff --git a/core-backend/models.go b/core-backend/models.go index 1650641..1784a8b 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -129,7 +129,22 @@ type Message struct { // one-tap undo") -- set via POST /messages/:id/retract, twin-authored // messages only. Retracted bool `gorm:"not null;default:false"` - CreatedAt time.Time + // DraftEdited is the implicit L1-approval-rate signal (PRD.md §5's + // "초안을 수정 없이 그대로 발송한 비율", vision.md's naturalness metric, + // deploy-checklist.md N4-12). Nil for human-authored messages and for + // any message not sent through the draft-approval flow (old rows, or a + // client that didn't send original_draft_text) -- only set to a + // concrete true/false for twin-mode messages where the client told us + // what the original AI draft text was, so we could diff it against + // what actually got sent. Never guessed. + DraftEdited *bool + // NaturalnessRating is the explicit "이 답장 나답아요?" signal (vision.md + // naturalness metric, PRD.md §5, deploy-checklist.md N4-12): true = 👍, + // false = 👎, nil = not rated yet. Set via POST /messages/:id/feedback, + // twin-authored messages only (rating a human's own words doesn't make + // sense for this metric). + NaturalnessRating *bool + CreatedAt time.Time } type TwinSettings struct { diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index b261ddb..c3990b4 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -234,7 +234,7 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co | ID | 작업 | Status | 비고 | |----|------|--------|------| | **N4-11** | 오프라인 메시지 큐 | **done (부분 검증)** (2026-08-03) | 서버는 이미 모든 메시지를 DB에 durable하게 저장 — 별도 큐를 새로 만들지 않고 `GET /conversations/:id/messages?since_id=`(신규, 파라미터 없으면 기존 전체 히스토리 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→...→30s 캡, 성공 시 리셋, `mobile/lib/services/ws_client.dart`) + 재연결/앱 포그라운드 복귀 시 since_id 캐치업(`mobile/lib/screens/chat_screen.dart`)으로 gap을 메움. `core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와 `mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합을 순수 함수로 뽑아 검증)로 확인한 부분과, **실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서 OS가 소켓을 어떻게 처리하는지는 단위 테스트로 증명 불가 — 실기기 Android QA 남음**(N4-C6b와 같은 프레이밍) | -| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 | +| **N4-12** | 자연스러움 피드백 UI | **done (계측만)** (2026-08-03) | vision 지표. **PoC 실행/결론이 아니라 캡처 장치** — 실제 %는 N5 이후 실 사용 데이터로. 암묵 신호: `core-backend/models.go` `Message.DraftEdited`(nullable — 사람 메시지/구버전 클라는 nil, 트윈 승인-발송 경로에서 클라이언트가 `original_draft_text`를 같이 보내면 서버가 diff해 true/false) + `mobile/lib/screens/chat_screen.dart` `_sendTwinApproved`가 `_pendingDraft.text`(편집 전 원본)를 함께 전송. 명시 신호: `Message.NaturalnessRating`(nullable bool) + `POST /messages/:id/feedback`(트윈 메시지만, 400 아니면 재제출은 덮어씀 — 409 아님) + `chat_screen.dart`/`message_bubble.dart`의 "이 답장 나답아요?" 원탭 👍/👎(한 번 탭하면 그 세션에서 다시 안 보여줌, `_ratedMessageIds` + `message_sync.dart`의 `ratedMessageIdsFrom()`). `/admin/metrics`에 `draft_unedited_rate`/`naturalness_positive_rate`(둘 다 분모 0일 때 0으로 zero-guard, `peer_veto_rate`와 동일 패턴) 추가, `adminDashboardHTML`에 카드 2개 추가. 백엔드 테스트 8개(`core-backend/main_test.go`) + 모바일 테스트 5개(`mobile/test/`) 추가, 전부 통과 | | **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 | | **N4-14** | 내부 release APK | todo | `docs/android-release.md` | | **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 30e36ca..70ca738 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -121,9 +121,12 @@ 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은 `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`) - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 - `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 - 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 - 보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 + `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). **2026-08-03 + 자연스러움 계측 인프라 추가(deploy-checklist.md N4-12, 아래 참고)**: 암묵 신호(초안 무수정 + 발송률)와 명시 신호(원탭 "이 답장 나답아요?" 피드백) 둘 다 캡처되어 `/admin/metrics`에 + 집계된다. **주의 — 이건 PoC 실행이나 실제 수치 결론이 아니라 계측/캡처 장치일 뿐이다.** + 실제 자연스러움 %는 실 베타 사용 데이터가 쌓여야 나온다(N5 이후). 안전선 위반 0건은 런타임에 + "수집"하는 지표라기보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 - [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**: 이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는 낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로 diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index 250876d..3bec49d 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -174,6 +174,8 @@ class ChatMessage { required this.text, required this.retracted, required this.createdAt, + this.draftEdited, + this.naturalnessRating, }); final int id; @@ -183,6 +185,15 @@ class ChatMessage { final String text; final bool retracted; final DateTime createdAt; + /// L1 approval-rate proxy (PRD.md §5, deploy-checklist.md N4-12) — null + /// for human messages and any twin message the server couldn't diff + /// against an original draft; otherwise true = edited before send, + /// false = sent verbatim. Purely informational on the client; the server + /// is the source of truth (computed once at send time). + final bool? draftEdited; + /// "이 답장 나답아요?" explicit feedback (vision.md metric, N4-12) — null + /// = not rated yet, true = 👍, false = 👎. + final bool? naturalnessRating; bool get isTwin => senderMode == SenderMode.twin; @@ -196,6 +207,8 @@ class ChatMessage { text: json['text'] as String? ?? '', retracted: json['retracted'] as bool? ?? false, createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + draftEdited: json['draft_edited'] as bool?, + naturalnessRating: json['naturalness_rating'] as bool?, ); } } diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 24d92b4..40f66d5 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -52,6 +52,10 @@ class _ChatScreenState extends State with WidgetsBindingObserver { bool _loadingHistory = true; late bool _floodBlocked; late final ApiClient _api; + // "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12): + // 이번 세션에서(또는 서버에서 이미) 평가된 메시지 id들 — 한 번 탭한 메시지엔 + // 화면이 다시 그려져도(리로드 없이) 뱃지를 다시 보여주지 않는다. + final _ratedMessageIds = {}; // 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각. // 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다. DateTime? _snoozedUntil; @@ -97,6 +101,7 @@ class _ChatScreenState extends State with WidgetsBindingObserver { _messages ..clear() ..addAll(merged); + _syncRatedMessageIds(merged); }); _scrollToEnd(); _markLatestRead(); @@ -194,6 +199,7 @@ class _ChatScreenState extends State with WidgetsBindingObserver { ..clear() ..addAll(history); _loadingHistory = false; + _syncRatedMessageIds(history); }); _scrollToEnd(); // 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 -- @@ -232,6 +238,28 @@ class _ChatScreenState extends State with WidgetsBindingObserver { }); } + /// 서버에서 이미 평가된 메시지(다른 세션/기기에서 탭했거나, 새로고침 전에 + /// 이미 평가된 경우)를 _ratedMessageIds에 반영 — UI를 다시 보여주지 않기 + /// 위함. 실제 판정은 message_sync.dart의 순수 함수(ratedMessageIdsFrom)로 + /// 분리해 위젯 없이도 테스트할 수 있게 했다. + void _syncRatedMessageIds(List messages) { + _ratedMessageIds.addAll(ratedMessageIdsFrom(messages)); + } + + /// "이 답장 나답아요?" 원탭 피드백(vision.md 지표, deploy-checklist.md + /// N4-12) — 트윈이 보낸 메시지 위에서만 노출(MessageBubble이 이미 twin + + /// isMine + !retracted로 걸러서 호출). 한 번 탭하면 다시 평가를 받지 않음. + Future _submitFeedback(ChatMessage message, bool natural) async { + setState(() => _ratedMessageIds.add(message.id)); + try { + await _api.submitMessageFeedback(message.id, natural); + } on ApiException { + // Best-effort — this is a soft signal, not a safety-critical action; + // if it fails to save server-side we don't re-offer the tap (that + // would look like a broken button), we just quietly drop it. + } + } + void _onEvent(Map event) { final retractionId = _socket!.parseRetractionId(event); if (retractionId != null) { @@ -247,6 +275,8 @@ class _ChatScreenState extends State with WidgetsBindingObserver { text: m.text, retracted: true, createdAt: m.createdAt, + draftEdited: m.draftEdited, + naturalnessRating: m.naturalnessRating, ); } }); @@ -368,6 +398,10 @@ class _ChatScreenState extends State with WidgetsBindingObserver { text: text, senderMode: SenderMode.twin, approved: true, + // 초안 무수정 발송률(PRD.md §5, N4-12): draft.text는 사용자가 편집 + // 컨트롤러(_draftEdit)를 건드리기 전 AI 초안 원문 — 서버가 이걸 + // 최종 text와 diff해 draft_edited를 계산한다. + originalDraftText: draft.text, ); setState(() { _pendingDraft = null; @@ -675,10 +709,18 @@ class _ChatScreenState extends State with WidgetsBindingObserver { itemCount: _messages.length, itemBuilder: (context, i) { final m = _messages[i]; + final isMine = me != null && m.senderId == me; return MessageBubble( message: m, - isMine: me != null && m.senderId == me, + isMine: isMine, onRetract: m.isTwin ? () => _retract(m) : null, + // "이 답장 나답아요?" (vision.md 지표, N4-12): 트윈이 + // 실제로 보낸(=내가 보낸) 메시지에만 노출, 이미 + // 평가된 메시지는 다시 보여주지 않는다. + alreadyRated: _ratedMessageIds.contains(m.id), + onFeedback: m.isTwin && isMine && !m.retracted + ? (natural) => _submitFeedback(m, natural) + : null, ); }, ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 7d3f094..bdaa37d 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -173,12 +173,28 @@ class ApiClient { required String text, SenderMode senderMode = SenderMode.human, bool approved = false, + // 초안 무수정 발송률(PRD.md §5, deploy-checklist.md N4-12): 트윈 + // 승인-발송 경로에서만 AI 초안 원문을 같이 보내 서버가 diff할 수 있게 + // 한다. null이면(사람 메시지, 또는 초안 기반이 아닌 발송) 아예 필드를 + // 넣지 않음 — relationshipTier/autonomyLevel 등과 같은 "값 있을 때만 + // 포함" 관례. + String? originalDraftText, }) async { final json = await _json('POST', '/conversations/$conversationId/messages', body: { 'sender_id': senderId, 'text': text, 'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human', if (approved) 'approved': true, + if (originalDraftText != null) 'original_draft_text': originalDraftText, + }); + return ChatMessage.fromJson(json); + } + + /// "이 답장 나답아요?" 자연스러움 피드백(vision.md 지표, N4-12) — 트윈 + /// 메시지에만 허용. 재제출 시 서버가 덮어쓴다(에러 아님). + Future submitMessageFeedback(int messageId, bool natural) async { + final json = await _json('POST', '/messages/$messageId/feedback', body: { + 'natural': natural, }); return ChatMessage.fromJson(json); } diff --git a/mobile/lib/services/message_sync.dart b/mobile/lib/services/message_sync.dart index 3ec9a6a..cbb8b85 100644 --- a/mobile/lib/services/message_sync.dart +++ b/mobile/lib/services/message_sync.dart @@ -53,3 +53,12 @@ List mergeNewMessages( if (toAdd.isEmpty) return List.of(existing); return [...existing, ...toAdd]; } + +/// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12) 순수 +/// 로직: [messages] 중 이미 평가된(naturalness_rating != null) 메시지 id들만 +/// 뽑아낸다. chat_screen.dart는 히스토리를 새로 불러올 때마다(또는 캐치업 +/// 때마다) 이 결과를 세션 상태의 "이미 평가됨" 집합에 합쳐 — 서버가 이미 +/// 알고 있는 평가는 화면이 다시 그려져도 뱅지/버튼을 다시 보여주지 않는다. +Set ratedMessageIdsFrom(List messages) { + return messages.where((m) => m.naturalnessRating != null).map((m) => m.id).toSet(); +} diff --git a/mobile/lib/widgets/message_bubble.dart b/mobile/lib/widgets/message_bubble.dart index cf4b3f1..643d18b 100644 --- a/mobile/lib/widgets/message_bubble.dart +++ b/mobile/lib/widgets/message_bubble.dart @@ -10,11 +10,18 @@ class MessageBubble extends StatelessWidget { required this.message, required this.isMine, this.onRetract, + this.alreadyRated = false, + this.onFeedback, }); final ChatMessage message; final bool isMine; final VoidCallback? onRetract; + // "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12). + // alreadyRated면(이번 세션에 이미 탭했거나 서버에 기록이 있으면) 버튼 대신 + // 조용한 완료 표시만 보여주고 다시 탭할 수 없게 한다 — 재평가 없음. + final bool alreadyRated; + final void Function(bool natural)? onFeedback; static String _time(DateTime dt) { final local = dt.toLocal(); @@ -131,6 +138,49 @@ class MessageBubble extends StatelessWidget { label: const Text('되돌리기'), ), ), + if (twin && !retracted && onFeedback != null) + Padding( + padding: const EdgeInsets.only(top: 2, right: 4, left: 4), + child: alreadyRated + ? Text( + '평가 고마워요', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 11, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '이 답장 나답아요?', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + const SizedBox(width: 2), + IconButton( + tooltip: '나다워요', + onPressed: () => onFeedback!(true), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + iconSize: 16, + color: scheme.onSurfaceVariant, + icon: const Icon(Icons.thumb_up_outlined), + ), + IconButton( + tooltip: '나답지 않아요', + onPressed: () => onFeedback!(false), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + iconSize: 16, + color: scheme.onSurfaceVariant, + icon: const Icon(Icons.thumb_down_outlined), + ), + ], + ), + ), ], ); diff --git a/mobile/test/message_sync_test.dart b/mobile/test/message_sync_test.dart index d54906d..81a3ab5 100644 --- a/mobile/test/message_sync_test.dart +++ b/mobile/test/message_sync_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:ykavu_mobile/models/models.dart'; import 'package:ykavu_mobile/services/message_sync.dart'; -ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage( +ChatMessage _msg(int id, {bool retracted = false, bool? naturalnessRating}) => ChatMessage( id: id, conversationId: 1, senderId: 1, @@ -10,6 +10,7 @@ ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage( text: 'msg-$id', retracted: retracted, createdAt: DateTime(2026, 8, 3), + naturalnessRating: naturalnessRating, ); void main() { @@ -82,4 +83,27 @@ void main() { expect(merged.length, 2); }); }); + + // "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12): + // chat_screen.dart는 이 순수 함수의 결과를 세션 상태의 "이미 평가됨" id + // 집합에 합쳐서, 히스토리를 다시 불러와도(화면 재진입, 캐치업 등) 이미 + // 평가된 메시지 위에 뱃지/버튼을 다시 보여주지 않는다. + group('ratedMessageIdsFrom', () { + test('picks up ids with a non-null naturalness rating (true or false)', () { + final messages = [ + _msg(1, naturalnessRating: true), + _msg(2, naturalnessRating: false), + _msg(3), + ]; + expect(ratedMessageIdsFrom(messages), {1, 2}); + }); + + test('empty list -> empty set', () { + expect(ratedMessageIdsFrom(const []), isEmpty); + }); + + test('no rated messages -> empty set', () { + expect(ratedMessageIdsFrom([_msg(1), _msg(2)]), isEmpty); + }); + }); } diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart index c983f7d..a2ddb2d 100644 --- a/mobile/test/models_test.dart +++ b/mobile/test/models_test.dart @@ -74,4 +74,37 @@ void main() { }); expect(withoutOverride.autonomyLevel, isNull); }); + + // 초안 무수정 발송률 + 자연스러움 피드백(PRD.md §5, deploy-checklist.md + // N4-12): 서버가 null을 보낼 수 있는 두 필드가 각각 null/true/false를 + // 그대로 통과시키는지 확인. + test('ChatMessage parses draft_edited and naturalness_rating when present', () { + final withBoth = ChatMessage.fromJson({ + 'id': 10, + 'conversation_id': 1, + 'sender_id': 1, + 'sender_mode': 'twin', + 'text': 'ㅇㅇ 좋아', + 'retracted': false, + 'draft_edited': true, + 'naturalness_rating': false, + 'created_at': '2026-08-03T00:00:00Z', + }); + expect(withBoth.draftEdited, isTrue); + expect(withBoth.naturalnessRating, isFalse); + }); + + test('ChatMessage defaults draft_edited and naturalness_rating to null when absent', () { + final withoutEither = ChatMessage.fromJson({ + 'id': 11, + 'conversation_id': 1, + 'sender_id': 1, + 'sender_mode': 'human', + 'text': '안녕', + 'retracted': false, + 'created_at': '2026-08-03T00:00:00Z', + }); + expect(withoutEither.draftEdited, isNull); + expect(withoutEither.naturalnessRating, isNull); + }); }