Compare commits
3 Commits
3a3a045d5d
...
febf1fc637
| Author | SHA1 | Date |
|---|---|---|
|
|
febf1fc637 | |
|
|
a956db754b | |
|
|
c35fad2737 |
|
|
@ -278,15 +278,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})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ func messageJSON(m Message) gin.H {
|
|||
"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 = `<!doctype html>
|
|||
['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)],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -129,6 +129,21 @@ type Message struct {
|
|||
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
||||
// messages only.
|
||||
Retracted bool `gorm:"not null;default:false"`
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,7 +232,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 | 완료 시 체크 |
|
||||
|
|
|
|||
|
|
@ -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]` 항목과 서로
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
bool _loadingHistory = true;
|
||||
late bool _floodBlocked;
|
||||
late final ApiClient _api;
|
||||
// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12):
|
||||
// 이번 세션에서(또는 서버에서 이미) 평가된 메시지 id들 — 한 번 탭한 메시지엔
|
||||
// 화면이 다시 그려져도(리로드 없이) 뱃지를 다시 보여주지 않는다.
|
||||
final _ratedMessageIds = <int>{};
|
||||
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
||||
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
||||
DateTime? _snoozedUntil;
|
||||
|
|
@ -97,6 +101,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
_messages
|
||||
..clear()
|
||||
..addAll(merged);
|
||||
_syncRatedMessageIds(merged);
|
||||
});
|
||||
_scrollToEnd();
|
||||
_markLatestRead();
|
||||
|
|
@ -194,6 +199,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
..clear()
|
||||
..addAll(history);
|
||||
_loadingHistory = false;
|
||||
_syncRatedMessageIds(history);
|
||||
});
|
||||
_scrollToEnd();
|
||||
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
||||
|
|
@ -232,6 +238,28 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
});
|
||||
}
|
||||
|
||||
/// 서버에서 이미 평가된 메시지(다른 세션/기기에서 탭했거나, 새로고침 전에
|
||||
/// 이미 평가된 경우)를 _ratedMessageIds에 반영 — UI를 다시 보여주지 않기
|
||||
/// 위함. 실제 판정은 message_sync.dart의 순수 함수(ratedMessageIdsFrom)로
|
||||
/// 분리해 위젯 없이도 테스트할 수 있게 했다.
|
||||
void _syncRatedMessageIds(List<ChatMessage> messages) {
|
||||
_ratedMessageIds.addAll(ratedMessageIdsFrom(messages));
|
||||
}
|
||||
|
||||
/// "이 답장 나답아요?" 원탭 피드백(vision.md 지표, deploy-checklist.md
|
||||
/// N4-12) — 트윈이 보낸 메시지 위에서만 노출(MessageBubble이 이미 twin +
|
||||
/// isMine + !retracted로 걸러서 호출). 한 번 탭하면 다시 평가를 받지 않음.
|
||||
Future<void> _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<String, dynamic> event) {
|
||||
final retractionId = _socket!.parseRetractionId(event);
|
||||
if (retractionId != null) {
|
||||
|
|
@ -247,6 +275,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
text: m.text,
|
||||
retracted: true,
|
||||
createdAt: m.createdAt,
|
||||
draftEdited: m.draftEdited,
|
||||
naturalnessRating: m.naturalnessRating,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -368,6 +398,10 @@ class _ChatScreenState extends State<ChatScreen> 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<ChatScreen> 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -194,12 +194,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<ChatMessage> submitMessageFeedback(int messageId, bool natural) async {
|
||||
final json = await _json('POST', '/messages/$messageId/feedback', body: {
|
||||
'natural': natural,
|
||||
});
|
||||
return ChatMessage.fromJson(json);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,3 +53,12 @@ List<ChatMessage> 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<int> ratedMessageIdsFrom(List<ChatMessage> messages) {
|
||||
return messages.where((m) => m.naturalnessRating != null).map((m) => m.id).toSet();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue