Compare commits
No commits in common. "febf1fc63706b61a9596dfd61be99394a90a624a" and "53f219a2680599b51f90c9d77f8036cda99871c0" have entirely different histories.
febf1fc637
...
53f219a268
16
AGENTS.md
16
AGENTS.md
|
|
@ -10,8 +10,8 @@ copying content into prompts or new files.
|
||||||
|
|
||||||
When documents disagree, follow this order:
|
When documents disagree, follow this order:
|
||||||
|
|
||||||
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정)
|
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 (확정)
|
||||||
and Q9 (L2 의미 — 제안 until Master confirms)
|
and Q8/Q9 (계정·설정 IA / L2 의미 — 제안 until Master confirms)
|
||||||
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
|
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
|
||||||
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
|
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
|
||||||
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
||||||
|
|
@ -19,12 +19,12 @@ When documents disagree, follow this order:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30;
|
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
|
||||||
Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default
|
sub-questions (default autonomy level, whitelist defaults, final branding)
|
||||||
autonomy level, whitelist defaults, final branding) stay open — do not invent
|
stay open — do not invent those. Q8/Q9 (logout/settings IA, L2 semantics) are
|
||||||
those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only
|
**제안** — implement only after Master marks them 확정; see
|
||||||
after Master marks it 확정; see `docs/account-settings-ia.md`. To reverse a Q,
|
`docs/account-settings-ia.md`. To reverse a Q, update `decision-log.md` and
|
||||||
update `decision-log.md` and derived docs in the same change.
|
derived docs in the same change.
|
||||||
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
||||||
current working answers.
|
current working answers.
|
||||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package main
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
@ -48,31 +47,15 @@ func contactJSON(ct Contact) gin.H {
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
InviteCode string `json:"invite_code" binding:"required"`
|
InviteCode string `json:"invite_code" binding:"required"`
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
// Q8b: re-enter closed beta with invite code (+ display_name for shared DEMO).
|
|
||||||
r.POST("/auth/login", func(c *gin.Context) {
|
r.POST("/auth/login", func(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var user User
|
|
||||||
if demoInviteEnabled() && isDemoInviteCode(req.InviteCode) {
|
|
||||||
name := strings.TrimSpace(req.DisplayName)
|
|
||||||
if name == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "display_name is required for DEMO invite login"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Demo signups store a unique per-user invite_code, not DEMO-YKAVU.
|
|
||||||
// Match the most recent user with this display_name (testers reuse names rarely).
|
|
||||||
if err := db.Where("display_name = ?", name).Order("id desc").First(&user).Error; err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "no user with that display_name under DEMO signup"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var invite InviteCode
|
var invite InviteCode
|
||||||
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
|
|
@ -82,15 +65,11 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var user User
|
||||||
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if name := strings.TrimSpace(req.DisplayName); name != "" && user.DisplayName != name {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "display_name does not match this invite"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session, err := createSession(db, user.ID)
|
session, err := createSession(db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
@ -278,7 +257,15 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
query.Order("id asc").Find(&messages)
|
query.Order("id asc").Find(&messages)
|
||||||
out := make([]gin.H, 0, len(messages))
|
out := make([]gin.H, 0, len(messages))
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
out = append(out, messageJSON(m))
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"messages": out})
|
c.JSON(http.StatusOK, gin.H{"messages": out})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
t.Fatalf("signup: %d", signup.StatusCode)
|
t.Fatalf("signup: %d", signup.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code, DisplayName: "로그인"})
|
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code})
|
||||||
if login.StatusCode != http.StatusOK {
|
if login.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -74,23 +74,6 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemoLoginRequiresDisplayName(t *testing.T) {
|
|
||||||
t.Setenv("ALLOW_DEMO_INVITE", "1")
|
|
||||||
server, _ := setupTestServer(t)
|
|
||||||
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
|
|
||||||
if signup.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("demo signup: %d", signup.StatusCode)
|
|
||||||
}
|
|
||||||
missing := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode})
|
|
||||||
if missing.StatusCode != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400 without display_name, got %d", missing.StatusCode)
|
|
||||||
}
|
|
||||||
ok := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
|
|
||||||
if ok.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 demo login, got %d", ok.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ func messageJSON(m Message) gin.H {
|
||||||
"sender_mode": m.SenderMode,
|
"sender_mode": m.SenderMode,
|
||||||
"text": m.Text,
|
"text": m.Text,
|
||||||
"retracted": m.Retracted,
|
"retracted": m.Retracted,
|
||||||
"draft_edited": m.DraftEdited,
|
|
||||||
"naturalness_rating": m.NaturalnessRating,
|
|
||||||
"created_at": m.CreatedAt,
|
"created_at": m.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,8 +251,6 @@ const adminDashboardHTML = `<!doctype html>
|
||||||
['twin msgs', data.messages_twin_total],
|
['twin msgs', data.messages_twin_total],
|
||||||
['escalations', data.escalations_total],
|
['escalations', data.escalations_total],
|
||||||
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
|
['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 req', data.draft_requests],
|
||||||
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
|
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
|
||||||
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
|
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
|
||||||
|
|
|
||||||
|
|
@ -31,20 +31,6 @@ type sendMessageRequest struct {
|
||||||
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
|
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
|
||||||
// human-authored messages.
|
// human-authored messages.
|
||||||
Approved bool `json:"approved"`
|
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 {
|
type updateTwinSettingsRequest struct {
|
||||||
|
|
@ -139,34 +125,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
|
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()
|
rt := runtimeMetrics.snapshot()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users_total": usersTotal,
|
"users_total": usersTotal,
|
||||||
|
|
@ -181,14 +139,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// vision.md doesn't pin down the exact denominator, so treat
|
// vision.md doesn't pin down the exact denominator, so treat
|
||||||
// this as a first approximation, not the final definition.
|
// this as a first approximation, not the final definition.
|
||||||
"peer_veto_rate": peerVetoRate,
|
"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.
|
// Process-local draft/AI timings (roadmap B). Reset on restart.
|
||||||
"draft_requests": rt.DraftRequests,
|
"draft_requests": rt.DraftRequests,
|
||||||
"draft_errors": rt.DraftErrors,
|
"draft_errors": rt.DraftErrors,
|
||||||
|
|
@ -421,23 +371,11 @@ 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{
|
message := Message{
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
SenderID: req.SenderID,
|
SenderID: req.SenderID,
|
||||||
SenderMode: req.SenderMode,
|
SenderMode: req.SenderMode,
|
||||||
Text: req.Text,
|
Text: req.Text,
|
||||||
DraftEdited: draftEdited,
|
|
||||||
}
|
}
|
||||||
if err := db.Create(&message).Error; err != nil {
|
if err := db.Create(&message).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
@ -494,46 +432,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
|
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) {
|
r.POST("/conversations/:id/draft", func(c *gin.Context) {
|
||||||
convID, ok := parseUintParam(c, "id")
|
convID, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -937,304 +937,6 @@ 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) {
|
func TestDraftMissingConversation(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
||||||
|
|
|
||||||
|
|
@ -129,21 +129,6 @@ type Message struct {
|
||||||
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
||||||
// messages only.
|
// messages only.
|
||||||
Retracted bool `gorm:"not null;default:false"`
|
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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@
|
||||||
|
|
||||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||||
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||||
PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다.
|
PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / L2 의미, 2026-08-03 제안)** 가
|
||||||
**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다.
|
열려 있다 — decision-log를 단일 기준으로 따른다. 계정 UX 상세는
|
||||||
계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md).
|
[`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
| # | 질문 | 결정 | 상태 |
|
| # | 질문 | 결정 | 상태 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
|
|
@ -107,7 +107,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)**
|
||||||
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
||||||
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
||||||
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
||||||
6. [`decision-log.md`](./decision-log.md) — Q1~Q8 확정 + Q9 제안, 계속 누적 기록
|
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 확정 + Q8/Q9 제안, 계속 누적 기록
|
||||||
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
||||||
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
||||||
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@
|
||||||
### 2.0 계정 · 입장 (클로즈드 베타)
|
### 2.0 계정 · 입장 (클로즈드 베타)
|
||||||
|
|
||||||
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
|
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
|
||||||
(`decision-log.md` Q8 확정, `invite-ops.md`, `account-settings-ia.md`).
|
(`decision-log.md` Q8 제안, `invite-ops.md`, `account-settings-ia.md`).
|
||||||
|
|
||||||
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
||||||
2. **이미 가입** — 초대 코드 + 표시 이름으로 로그인(토큰 재발급) → 메인
|
2. **이미 가입** — 같은 초대 코드로 로그인(토큰 재발급) → 메인
|
||||||
(`DEMO-YKAVU`는 표시 이름으로 사용자 구분)
|
|
||||||
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
||||||
|
(2026-08-03 기준 앱 UI 미완 — P0 갭)
|
||||||
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
||||||
|
|
||||||
### 2.1 온보딩 (말투 학습)
|
### 2.1 온보딩 (말투 학습)
|
||||||
|
|
@ -56,7 +56,7 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
|
||||||
| 기능 | 설명 |
|
| 기능 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
||||||
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 |
|
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 (P0 갭 — UI 미완) |
|
||||||
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
||||||
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
||||||
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
||||||
|
|
||||||
권위: [`decision-log.md`](./decision-log.md) Q8(확정)·Q9(제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
권위: [`decision-log.md`](./decision-log.md) Q8·Q9 (제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
||||||
Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코드 범위를 넓히지 않는다.
|
이 문서는 **구현 전에** 제품 경계를 고정하기 위한 명세다. Q8/Q9가 `확정`되기 전에는
|
||||||
|
코드를 크게 바꾸지 않는다.
|
||||||
|
|
||||||
## 1. 현재 상태 (as-is)
|
## 1. 현재 상태 (as-is)
|
||||||
|
|
||||||
|
|
@ -14,7 +15,7 @@ Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코
|
||||||
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
||||||
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
||||||
|
|
||||||
## 2. Phase 1 목표 모델 (to-be, Q8 확정)
|
## 2. Phase 1 목표 모델 (to-be, Q8 제안)
|
||||||
|
|
||||||
### 2.1 계정
|
### 2.1 계정
|
||||||
|
|
||||||
|
|
@ -29,12 +30,10 @@ Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코
|
||||||
→ 토큰 유효? → 메인(대화 목록)
|
→ 토큰 유효? → 메인(대화 목록)
|
||||||
→ 없음 → [입장 화면]
|
→ 없음 → [입장 화면]
|
||||||
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
||||||
└─ 이미 가입 (초대 코드 + 표시 이름) → 메인
|
└─ 이미 가입 (초대 코드로 로그인) → 메인
|
||||||
```
|
```
|
||||||
|
|
||||||
- 「이미 가입」은 `POST /auth/login`으로 새 세션 토큰을 받는다.
|
- 「이미 가입」은 같은 초대 코드로 `POST /auth/login`을 호출해 새 세션 토큰을 받는다.
|
||||||
- 일반 초대: 사용된 초대 코드 → 사용자. 표시 이름이 있으면 일치해야 함.
|
|
||||||
- `DEMO-YKAVU`: 표시 이름 **필수** (공유 코드라 이름으로 사용자 구분).
|
|
||||||
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
||||||
|
|
||||||
### 2.3 로그아웃 (P0 갭)
|
### 2.3 로그아웃 (P0 갭)
|
||||||
|
|
@ -52,7 +51,7 @@ Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코
|
||||||
|
|
||||||
| 항목 | 화면 | 비고 |
|
| 항목 | 화면 | 비고 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 말투 · 페르소나 | `OnboardingToneScreen` | 설정 허브에서 진입 |
|
| 말투 샘플 | `OnboardingToneScreen` | 기존 |
|
||||||
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
||||||
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
||||||
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
||||||
|
|
@ -93,12 +92,12 @@ Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코
|
||||||
|
|
||||||
## 4. 수락 기준 (구현 착수 시)
|
## 4. 수락 기준 (구현 착수 시)
|
||||||
|
|
||||||
**계정/설정 (Q8 확정)**
|
**계정/설정 (Q8 확정 후)**
|
||||||
|
|
||||||
- [x] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
- [ ] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
||||||
- [x] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
- [ ] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
||||||
- [x] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
- [ ] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
||||||
- [x] 이메일/비번 UI를 추가하지 않음
|
- [ ] 이메일/비번 UI를 추가하지 않음
|
||||||
|
|
||||||
**L2 정렬 (Q9 확정 후)**
|
**L2 정렬 (Q9 확정 후)**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,21 @@
|
||||||
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
||||||
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
|
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
|
||||||
|
|
||||||
## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 확정
|
## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 제안
|
||||||
|
|
||||||
2026-08-03 Master 피드백 후 문서화 → **2026-08-03 Master 「1번부터 진행」으로 Q8 확정·구현**.
|
2026-08-03 Master 피드백: 앱에 **로그아웃/설정 진입이 불명확**하고, 일반 이메일·비밀번호
|
||||||
|
계정 시스템처럼 보이지 않는다. 코드부터 넣지 말고 문서에 먼저 못을 박는다.
|
||||||
|
|
||||||
| # | 질문 | 결정 | 근거 | 상태 |
|
| # | 질문 | 제안 결정 | 근거 | 상태 |
|
||||||
|---|------|------|------|------|
|
|---|------|----------|------|------|
|
||||||
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 + `invite-ops.md` | 확정 |
|
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 자체 앱 베타 + `invite-ops.md`. 인증 표면을 키우면 가설 검증보다 계정 인프라에 시간이 간다 | 제안 |
|
||||||
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름**으로 토큰 재발급. 입장 화면에 「이미 가입」탭. (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) | 서버 login 확장 + 앱 UI | 확정 |
|
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름 재입력**으로 토큰 재발급. 가입 화면에 「이미 가입」경로 명시 | 서버에 `POST /auth/login`(초대 코드)은 있음. 앱 UI가 없음 | 제안 |
|
||||||
| Q8c | 로그아웃? | **P0**. 설정에서 로그아웃 → 현재 세션 revoke + 로컬 토큰/유저 클리어 → 입장 화면 | `SessionsScreen`만으로는 불완전했음 | 확정 |
|
| Q8c | 로그아웃? | **필수 P0 갭**. 메뉴에서 로그아웃 → 서버 현재 세션 revoke + 로컬 토큰/유저 상태 클리어 → 가입/로그인 화면 | 지금 `SessionsScreen` 종료는 로컬 클리어가 없어 불완전 | 제안 |
|
||||||
| Q8d | 설정 IA? | 대화 목록 **설정** 화면: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 발견성 | 확정 |
|
| Q8d | 설정 IA? | 대화 목록 **⋮ 또는 설정 진입점** 하나로 묶음: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 자율성만 깊숙이 있어 발견성이 떨어짐 | 제안 |
|
||||||
| Q8e | 계정 삭제/탈퇴? | Phase 1 앱 UI **비범위** (운영 수동) | 베타 범위 | 확정 |
|
| Q8e | 계정 삭제/탈퇴? | Phase 1에서는 **운영자 수동/후속**. 베타 UX에 「탈퇴」를 넣지 않음(초대 코드 1회용 정책과 맞춤). 필요 시 C 이후 별도 Q | 탈퇴·GDPR급 삭제는 베타 가설 검증보다 큼 | 제안 |
|
||||||
|
|
||||||
|
**Master 승인 시** 위 행 상태를 `확정`으로 바꾸고 `PRD.md` / `tech-design.md` / `roadmap.md`의
|
||||||
|
대응 항목을 같이 갱신한 뒤 구현한다.
|
||||||
|
|
||||||
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
|
|
@ -60,5 +64,5 @@
|
||||||
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
||||||
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||||
- 베타 참가자 모집 규모와 방식
|
- 베타 참가자 모집 규모와 방식
|
||||||
- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정)
|
- Q8 / Q9 제안 → Master 확정
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
||||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||||
- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃
|
- **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 —
|
||||||
- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안** —
|
[`decision-log.md`](./decision-log.md) Q8/Q9 **제안**,
|
||||||
[`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지
|
[`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음**
|
||||||
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
||||||
|
|
||||||
### NEXT 순서
|
### NEXT 순서
|
||||||
|
|
@ -232,7 +232,7 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
|
||||||
| ID | 작업 | Status | 비고 |
|
| 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-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 | **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-12** | 자연스러움 피드백 UI | todo | vision 지표 |
|
||||||
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
||||||
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
||||||
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
|
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,9 @@
|
||||||
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
|
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
|
||||||
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
|
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
|
||||||
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
|
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
|
||||||
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). **2026-08-03
|
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
|
||||||
자연스러움 계측 인프라 추가(deploy-checklist.md N4-12, 아래 참고)**: 암묵 신호(초안 무수정
|
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
|
||||||
발송률)와 명시 신호(원탭 "이 답장 나답아요?" 피드백) 둘 다 캡처되어 `/admin/metrics`에
|
보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
||||||
집계된다. **주의 — 이건 PoC 실행이나 실제 수치 결론이 아니라 계측/캡처 장치일 뿐이다.**
|
|
||||||
실제 자연스러움 %는 실 베타 사용 데이터가 쌓여야 나온다(N5 이후). 안전선 위반 0건은 런타임에
|
|
||||||
"수집"하는 지표라기보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
|
||||||
- [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**:
|
- [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**:
|
||||||
이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는
|
이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는
|
||||||
낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
|
낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
|
||||||
|
|
@ -333,11 +330,11 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||||
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||||
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||||
- [x] **계정·설정 IA** (`decision-log` Q8 확정, [`account-settings-ia.md`](./account-settings-ia.md))
|
- [ ] **계정·설정 IA 갭** (`decision-log` Q8 제안, [`account-settings-ia.md`](./account-settings-ia.md))
|
||||||
— 이메일/비번 도입 금지(Phase 1)
|
— Master 확정 후 착수. 이메일/비번 도입 금지(Phase 1)
|
||||||
- [x] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`, DEMO는 display_name 필수)
|
- [ ] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`) 구분
|
||||||
- [x] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
- [ ] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
||||||
- [x] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
- [ ] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
||||||
|
|
||||||
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||||
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
|
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
|
||||||
| `POST /auth/login` | 사용된 초대 코드(+ 선택적 display_name) → 새 Session. DEMO는 display_name 필수 |
|
| `POST /auth/login` | 이미 사용된 초대 코드 → 새 Session (앱 UI 갭) |
|
||||||
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
|
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
|
||||||
| 클라이언트 | `shared_preferences`에 토큰 저장; 로그아웃 시 revoke + 로컬 삭제 → 입장 화면 |
|
| 클라이언트 | `shared_preferences`에 토큰 저장; **로그아웃 시 revoke + 로컬 삭제** (갭) |
|
||||||
|
|
||||||
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
||||||
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,6 @@ class ChatMessage {
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.retracted,
|
required this.retracted,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.draftEdited,
|
|
||||||
this.naturalnessRating,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
|
|
@ -185,15 +183,6 @@ class ChatMessage {
|
||||||
final String text;
|
final String text;
|
||||||
final bool retracted;
|
final bool retracted;
|
||||||
final DateTime createdAt;
|
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;
|
bool get isTwin => senderMode == SenderMode.twin;
|
||||||
|
|
||||||
|
|
@ -207,8 +196,6 @@ class ChatMessage {
|
||||||
text: json['text'] as String? ?? '',
|
text: json['text'] as String? ?? '',
|
||||||
retracted: json['retracted'] as bool? ?? false,
|
retracted: json['retracted'] as bool? ?? false,
|
||||||
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
||||||
draftEdited: json['draft_edited'] as bool?,
|
|
||||||
naturalnessRating: json['naturalness_rating'] as bool?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
bool _loadingHistory = true;
|
bool _loadingHistory = true;
|
||||||
late bool _floodBlocked;
|
late bool _floodBlocked;
|
||||||
late final ApiClient _api;
|
late final ApiClient _api;
|
||||||
// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12):
|
|
||||||
// 이번 세션에서(또는 서버에서 이미) 평가된 메시지 id들 — 한 번 탭한 메시지엔
|
|
||||||
// 화면이 다시 그려져도(리로드 없이) 뱃지를 다시 보여주지 않는다.
|
|
||||||
final _ratedMessageIds = <int>{};
|
|
||||||
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
||||||
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
||||||
DateTime? _snoozedUntil;
|
DateTime? _snoozedUntil;
|
||||||
|
|
@ -101,7 +97,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
_messages
|
_messages
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(merged);
|
..addAll(merged);
|
||||||
_syncRatedMessageIds(merged);
|
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
_markLatestRead();
|
_markLatestRead();
|
||||||
|
|
@ -199,7 +194,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(history);
|
..addAll(history);
|
||||||
_loadingHistory = false;
|
_loadingHistory = false;
|
||||||
_syncRatedMessageIds(history);
|
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
||||||
|
|
@ -238,28 +232,6 @@ 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) {
|
void _onEvent(Map<String, dynamic> event) {
|
||||||
final retractionId = _socket!.parseRetractionId(event);
|
final retractionId = _socket!.parseRetractionId(event);
|
||||||
if (retractionId != null) {
|
if (retractionId != null) {
|
||||||
|
|
@ -275,8 +247,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
text: m.text,
|
text: m.text,
|
||||||
retracted: true,
|
retracted: true,
|
||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
draftEdited: m.draftEdited,
|
|
||||||
naturalnessRating: m.naturalnessRating,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -398,10 +368,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
text: text,
|
text: text,
|
||||||
senderMode: SenderMode.twin,
|
senderMode: SenderMode.twin,
|
||||||
approved: true,
|
approved: true,
|
||||||
// 초안 무수정 발송률(PRD.md §5, N4-12): draft.text는 사용자가 편집
|
|
||||||
// 컨트롤러(_draftEdit)를 건드리기 전 AI 초안 원문 — 서버가 이걸
|
|
||||||
// 최종 text와 diff해 draft_edited를 계산한다.
|
|
||||||
originalDraftText: draft.text,
|
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingDraft = null;
|
_pendingDraft = null;
|
||||||
|
|
@ -709,18 +675,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
itemCount: _messages.length,
|
itemCount: _messages.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final m = _messages[i];
|
final m = _messages[i];
|
||||||
final isMine = me != null && m.senderId == me;
|
|
||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
message: m,
|
message: m,
|
||||||
isMine: isMine,
|
isMine: me != null && m.senderId == me,
|
||||||
onRetract: m.isTwin ? () => _retract(m) : null,
|
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,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import '../services/snooze_service.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/my_user_id_chip.dart';
|
import '../widgets/my_user_id_chip.dart';
|
||||||
|
import 'autonomy_settings_screen.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'inbox_screen.dart';
|
import 'inbox_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'onboarding_tone_screen.dart';
|
||||||
|
|
||||||
class ConversationListScreen extends StatefulWidget {
|
class ConversationListScreen extends StatefulWidget {
|
||||||
const ConversationListScreen({super.key});
|
const ConversationListScreen({super.key});
|
||||||
|
|
@ -261,14 +262,30 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.contacts_outlined),
|
icon: const Icon(Icons.contacts_outlined),
|
||||||
),
|
),
|
||||||
IconButton(
|
PopupMenuButton<VoidCallback>(
|
||||||
tooltip: '설정',
|
tooltip: '더보기',
|
||||||
onPressed: () {
|
icon: const Icon(Icons.more_vert),
|
||||||
Navigator.of(context).push(
|
onSelected: (action) => action(),
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
itemBuilder: (context) => [
|
||||||
);
|
PopupMenuItem(
|
||||||
},
|
value: () => Navigator.of(context)
|
||||||
icon: const Icon(Icons.settings_outlined),
|
.push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
|
||||||
|
child: const ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(Icons.record_voice_over_outlined),
|
||||||
|
title: Text('말투 샘플'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: () => Navigator.of(context)
|
||||||
|
.push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())),
|
||||||
|
child: const ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(Icons.tune),
|
||||||
|
title: Text('자율성 설정'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -57,16 +57,13 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (ok != true || !mounted) return;
|
if (ok != true) return;
|
||||||
if (isCurrent) {
|
|
||||||
// Q8c — clear local token and return to entry via AuthGate.
|
|
||||||
await session.logout();
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await session.api.revokeSession(session.user!.id, id.toInt());
|
await session.api.revokeSession(session.user!.id, id.toInt());
|
||||||
|
if (isCurrent && mounted) {
|
||||||
|
// Soft signal — full logout clearing is a follow-up; reload list for now.
|
||||||
|
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
|
||||||
|
}
|
||||||
await _load();
|
await _load();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
|
||||||
import 'autonomy_settings_screen.dart';
|
|
||||||
import 'data_flow_screen.dart';
|
|
||||||
import 'onboarding_tone_screen.dart';
|
|
||||||
import 'sessions_screen.dart';
|
|
||||||
|
|
||||||
/// Account / settings hub (Q8).
|
|
||||||
///
|
|
||||||
/// Groups tone, autonomy, sessions, data-flow, and logout in one place
|
|
||||||
/// so the conversation list menu stays short.
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
|
||||||
const SettingsScreen({super.key});
|
|
||||||
|
|
||||||
Future<void> _confirmLogout(BuildContext context) async {
|
|
||||||
final ok = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('로그아웃'),
|
|
||||||
content: const Text(
|
|
||||||
'이 기기에서 로그아웃할까요?\n'
|
|
||||||
'다시 쓰려면 초대 코드와 표시 이름으로 로그인해야 합니다.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('취소'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
child: const Text('로그아웃'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (ok != true || !context.mounted) return;
|
|
||||||
|
|
||||||
final session = context.read<SessionState>();
|
|
||||||
await session.logout();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
// Pop back to root; AuthGate rebuilds to SignupScreen.
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final session = context.watch<SessionState>();
|
|
||||||
final user = session.user;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('설정')),
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
|
||||||
if (user != null)
|
|
||||||
ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
child: Text(
|
|
||||||
user.displayName.isNotEmpty
|
|
||||||
? String.fromCharCode(user.displayName.runes.first).toUpperCase()
|
|
||||||
: '?',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(user.displayName),
|
|
||||||
subtitle: Text(
|
|
||||||
'user #${user.id}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.record_voice_over_outlined),
|
|
||||||
title: const Text('말투 · 페르소나'),
|
|
||||||
subtitle: const Text('트윈이 쓰는 말투와 역할'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const OnboardingToneScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.tune),
|
|
||||||
title: const Text('자율성 수준'),
|
|
||||||
subtitle: const Text('L0 · L1 · L2'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const AutonomySettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.devices_outlined),
|
|
||||||
title: const Text('로그인 세션'),
|
|
||||||
subtitle: const Text('기기별 세션 조회 · 폐기'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const SessionsScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.account_tree_outlined),
|
|
||||||
title: const Text('데이터 흐름'),
|
|
||||||
subtitle: const Text('내 데이터가 어디로 가는지'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const DataFlowScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(height: 24),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.logout, color: theme.colorScheme.error),
|
|
||||||
title: Text(
|
|
||||||
'로그아웃',
|
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
|
||||||
),
|
|
||||||
subtitle: const Text('이 기기 세션 종료'),
|
|
||||||
onTap: () => _confirmLogout(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ import '../widgets/brand_mark.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/primary_gradient_button.dart';
|
import '../widgets/primary_gradient_button.dart';
|
||||||
|
|
||||||
/// Closed-beta entry: signup (new invite) or login (already registered).
|
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -17,26 +16,12 @@ class SignupScreen extends StatefulWidget {
|
||||||
State<SignupScreen> createState() => _SignupScreenState();
|
State<SignupScreen> createState() => _SignupScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignupScreenState extends State<SignupScreen>
|
class _SignupScreenState extends State<SignupScreen> {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
final _invite = TextEditingController();
|
final _invite = TextEditingController();
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
late final TabController _tabs;
|
|
||||||
|
|
||||||
bool get _isLogin => _tabs.index == 1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabs = TabController(length: 2, vsync: this);
|
|
||||||
_tabs.addListener(() {
|
|
||||||
if (!_tabs.indexIsChanging) setState(() {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tabs.dispose();
|
|
||||||
_invite.dispose();
|
_invite.dispose();
|
||||||
_name.dispose();
|
_name.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -48,16 +33,6 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit(SessionState session) async {
|
|
||||||
final invite = _invite.text.trim();
|
|
||||||
final name = _name.text.trim();
|
|
||||||
if (_isLogin) {
|
|
||||||
await session.login(invite, name);
|
|
||||||
} else {
|
|
||||||
await session.signup(invite, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -65,13 +40,8 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: Padding(
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
||||||
child: IntrinsicHeight(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -90,27 +60,12 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
TabBar(
|
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
||||||
controller: _tabs,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: '새로 가입'),
|
|
||||||
Tab(text: '이미 가입'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
_isLogin
|
|
||||||
? '초대 코드와 표시 이름으로 다시 로그인합니다'
|
|
||||||
: '초대 코드로 클로즈드 베타에 참여합니다',
|
|
||||||
style: theme.textTheme.labelLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DemoTestPanel(
|
_DemoTestPanel(
|
||||||
onFill: _fillDemoCredentials,
|
onFill: _fillDemoCredentials,
|
||||||
onCopy: () async {
|
onCopy: () async {
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
||||||
const ClipboardData(text: AppConfig.demoInviteCode),
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
||||||
|
|
@ -130,14 +85,15 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _name,
|
controller: _name,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '표시 이름',
|
labelText: '표시 이름',
|
||||||
hintText: _isLogin ? '가입 때 쓴 이름 (DEMO는 필수)' : null,
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) {
|
onSubmitted: (_) {
|
||||||
if (!session.loading) _submit(session);
|
if (!session.loading) {
|
||||||
|
session.signup(_invite.text.trim(), _name.text.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (session.error != null) ...[
|
if (session.error != null) ...[
|
||||||
|
|
@ -147,9 +103,11 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_PressScale(
|
_PressScale(
|
||||||
child: PrimaryGradientButton(
|
child: PrimaryGradientButton(
|
||||||
label: _isLogin ? '로그인' : '시작하기',
|
label: '시작하기',
|
||||||
loading: session.loading,
|
loading: session.loading,
|
||||||
onPressed: session.loading ? null : () => _submit(session),
|
onPressed: session.loading
|
||||||
|
? null
|
||||||
|
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
@ -158,10 +116,6 @@ class _SignupScreenState extends State<SignupScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,27 +76,6 @@ class ApiClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Q8b — re-issue a session for an existing invite-based account.
|
|
||||||
Future<({User user, String token})> login({
|
|
||||||
required String inviteCode,
|
|
||||||
required String displayName,
|
|
||||||
}) async {
|
|
||||||
final json = await _json('POST', '/auth/login', body: {
|
|
||||||
'invite_code': inviteCode,
|
|
||||||
'display_name': displayName,
|
|
||||||
});
|
|
||||||
final token = json['token'] as String? ?? '';
|
|
||||||
authToken = token;
|
|
||||||
return (
|
|
||||||
user: User(
|
|
||||||
id: json['id'] as int,
|
|
||||||
displayName: json['display_name'] as String? ?? displayName,
|
|
||||||
inviteCode: inviteCode,
|
|
||||||
),
|
|
||||||
token: token,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<ConversationSummary>> listConversations() async {
|
Future<List<ConversationSummary>> listConversations() async {
|
||||||
final obj = await _getObject('/conversations');
|
final obj = await _getObject('/conversations');
|
||||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||||
|
|
@ -194,28 +173,12 @@ class ApiClient {
|
||||||
required String text,
|
required String text,
|
||||||
SenderMode senderMode = SenderMode.human,
|
SenderMode senderMode = SenderMode.human,
|
||||||
bool approved = false,
|
bool approved = false,
|
||||||
// 초안 무수정 발송률(PRD.md §5, deploy-checklist.md N4-12): 트윈
|
|
||||||
// 승인-발송 경로에서만 AI 초안 원문을 같이 보내 서버가 diff할 수 있게
|
|
||||||
// 한다. null이면(사람 메시지, 또는 초안 기반이 아닌 발송) 아예 필드를
|
|
||||||
// 넣지 않음 — relationshipTier/autonomyLevel 등과 같은 "값 있을 때만
|
|
||||||
// 포함" 관례.
|
|
||||||
String? originalDraftText,
|
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
|
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
|
||||||
'sender_id': senderId,
|
'sender_id': senderId,
|
||||||
'text': text,
|
'text': text,
|
||||||
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
|
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
|
||||||
if (approved) 'approved': true,
|
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);
|
return ChatMessage.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,3 @@ List<ChatMessage> mergeNewMessages(
|
||||||
if (toAdd.isEmpty) return List.of(existing);
|
if (toAdd.isEmpty) return List.of(existing);
|
||||||
return [...existing, ...toAdd];
|
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,15 @@ class SessionState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
await _persistAuth(result.user, result.token, resetToneOnboarding: true);
|
user = result.user;
|
||||||
|
toneOnboardingDone = false;
|
||||||
|
_db ??= await _openDb();
|
||||||
|
await _db!.setBoolKv(_kToneDone, false);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_kUserId, result.user.id);
|
||||||
|
await prefs.setString(_kDisplayName, result.user.displayName);
|
||||||
|
await prefs.setString(_kInvite, result.user.inviteCode);
|
||||||
|
await prefs.setString(_kToken, result.token);
|
||||||
await _registerDeviceTokenBestEffort();
|
await _registerDeviceTokenBestEffort();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
|
|
@ -121,71 +129,6 @@ class SessionState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Q8b — already registered: invite code + display name → new session.
|
|
||||||
Future<void> login(String inviteCode, String displayName) async {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
final result = await _api.login(inviteCode: inviteCode, displayName: displayName);
|
|
||||||
await _persistAuth(result.user, result.token, resetToneOnboarding: false);
|
|
||||||
await _registerDeviceTokenBestEffort();
|
|
||||||
} on ApiException catch (e) {
|
|
||||||
error = '로그인 실패 (${e.statusCode}): ${e.body}';
|
|
||||||
} catch (e) {
|
|
||||||
error = e.toString();
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _persistAuth(User u, String token, {required bool resetToneOnboarding}) async {
|
|
||||||
user = u;
|
|
||||||
_api.authToken = token;
|
|
||||||
_db ??= await _openDb();
|
|
||||||
if (resetToneOnboarding) {
|
|
||||||
toneOnboardingDone = false;
|
|
||||||
await _db!.setBoolKv(_kToneDone, false);
|
|
||||||
} else {
|
|
||||||
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
|
|
||||||
styleExamples = await _db!.loadToneSamples();
|
|
||||||
}
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setInt(_kUserId, u.id);
|
|
||||||
await prefs.setString(_kDisplayName, u.displayName);
|
|
||||||
await prefs.setString(_kInvite, u.inviteCode);
|
|
||||||
await prefs.setString(_kToken, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Q8c — revoke current server session when possible, then clear local auth.
|
|
||||||
Future<void> logout() async {
|
|
||||||
final u = user;
|
|
||||||
final token = _api.authToken;
|
|
||||||
if (u != null && token != null && token.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
final sessions = await _api.listSessions(u.id);
|
|
||||||
for (final s in sessions) {
|
|
||||||
if (s['is_current'] == true && s['id'] is num) {
|
|
||||||
await _api.revokeSession(u.id, (s['id'] as num).toInt());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('logout revoke skipped: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user = null;
|
|
||||||
error = null;
|
|
||||||
_api.authToken = null;
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove(_kUserId);
|
|
||||||
await prefs.remove(_kDisplayName);
|
|
||||||
await prefs.remove(_kInvite);
|
|
||||||
await prefs.remove(_kToken);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
||||||
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
styleExamples = cleaned;
|
styleExamples = cleaned;
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,11 @@ class MessageBubble extends StatelessWidget {
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
this.onRetract,
|
this.onRetract,
|
||||||
this.alreadyRated = false,
|
|
||||||
this.onFeedback,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final ChatMessage message;
|
final ChatMessage message;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
final VoidCallback? onRetract;
|
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) {
|
static String _time(DateTime dt) {
|
||||||
final local = dt.toLocal();
|
final local = dt.toLocal();
|
||||||
|
|
@ -138,49 +131,6 @@ class MessageBubble extends StatelessWidget {
|
||||||
label: const Text('되돌리기'),
|
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/models/models.dart';
|
||||||
import 'package:ykavu_mobile/services/message_sync.dart';
|
import 'package:ykavu_mobile/services/message_sync.dart';
|
||||||
|
|
||||||
ChatMessage _msg(int id, {bool retracted = false, bool? naturalnessRating}) => ChatMessage(
|
ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage(
|
||||||
id: id,
|
id: id,
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
|
|
@ -10,7 +10,6 @@ ChatMessage _msg(int id, {bool retracted = false, bool? naturalnessRating}) => C
|
||||||
text: 'msg-$id',
|
text: 'msg-$id',
|
||||||
retracted: retracted,
|
retracted: retracted,
|
||||||
createdAt: DateTime(2026, 8, 3),
|
createdAt: DateTime(2026, 8, 3),
|
||||||
naturalnessRating: naturalnessRating,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -83,27 +82,4 @@ void main() {
|
||||||
expect(merged.length, 2);
|
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,37 +74,4 @@ void main() {
|
||||||
});
|
});
|
||||||
expect(withoutOverride.autonomyLevel, isNull);
|
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