diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py index bfc6d6f..397b3e4 100644 --- a/ai-service/app/generation.py +++ b/ai-service/app/generation.py @@ -9,6 +9,7 @@ import os from pathlib import Path from .escalation_filter import check as check_escalation +from .identity import check_identity_question def load_dotenv_if_present(): @@ -39,6 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화 - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ [ESCALATE] 이 내용은 본인 확인이 필요합니다. - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. +- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \ +(서버가 고정 문구로 먼저 처리하지만, 여기까지 온 경우에도 분신이라고 답한다). (이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" @@ -65,10 +68,16 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "ok": text is the generated draft. """ - gate = check_escalation(last_incoming_text(context_lines)) + incoming = last_incoming_text(context_lines) + gate = check_escalation(incoming) if gate.escalate: return "escalate", gate.reason + # Honest identity answers must be fixed copy, not LLM-invented (AGENTS.md). + identity = check_identity_question(incoming) + if identity.matched: + return "ok", identity.reply + api_key = api_key or os.environ.get("GEMINI_API_KEY") if not api_key: return "no_key", build_user_prompt(style_examples, context_lines) diff --git a/ai-service/app/identity.py b/ai-service/app/identity.py new file mode 100644 index 0000000..cfffc45 --- /dev/null +++ b/ai-service/app/identity.py @@ -0,0 +1,40 @@ +"""Fixed honest-identity answers (AGENTS.md / PRD §3.1). + +When the peer asks whether they are talking to the human or the twin, the +twin must answer as a twin with a stable phrase — not invent wording via LLM. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Fixed product copy — tune only after PoC #3; do not invent alternate defaults. +IDENTITY_REPLY = ( + "지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘." +) + +# Incoming peer questions that should trigger the fixed identity reply. +_IDENTITY_QUESTION = re.compile( + r"(" + r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|지금\s*(본인|분신)" + r"|진짜\s*(야|임|인가요)" + r"|너\s*(사람|본인|분신)" + r")", + re.IGNORECASE, +) + + +@dataclass +class IdentityHit: + matched: bool + reply: str = "" + + +def check_identity_question(text: str) -> IdentityHit: + if not text or not text.strip(): + return IdentityHit(False) + if _IDENTITY_QUESTION.search(text.strip()): + return IdentityHit(True, IDENTITY_REPLY) + return IdentityHit(False) diff --git a/ai-service/tests/test_identity.py b/ai-service/tests/test_identity.py new file mode 100644 index 0000000..1170ba9 --- /dev/null +++ b/ai-service/tests/test_identity.py @@ -0,0 +1,29 @@ +from app.identity import IDENTITY_REPLY, check_identity_question +from app.generation import draft_reply + + +def test_identity_phrases_match(): + for q in [ + "지금 본인이야 분신이야?", + "분신이야?", + "너 본인 맞아?", + "진짜야?", + ]: + hit = check_identity_question(q) + assert hit.matched, q + assert hit.reply == IDENTITY_REPLY + + +def test_identity_non_match(): + assert not check_identity_question("오늘 저녁 뭐 먹을래?").matched + + +def test_draft_returns_fixed_identity_without_llm(monkeypatch): + # Ensure we never need an API key for this path. + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + status, text = draft_reply( + ["ㅇㅇ 알겠음"], + ["상대: 지금 본인이야 분신이야?"], + ) + assert status == "ok" + assert text == IDENTITY_REPLY diff --git a/core-backend/README.md b/core-backend/README.md index 3d47140..8f61d14 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,7 +29,17 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 - 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer) - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 -- `/invites`, `/admin/metrics`는 `Authorization: Bearer $ADMIN_API_TOKEN` +- `/invites`, `/admin/metrics`, `/admin/dashboard`는 `Authorization: Bearer $ADMIN_API_TOKEN` + (`/admin/dashboard`는 `?token=` 쿼리로도 토큰을 넘길 수 있음) + +메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다 +(`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`). + +Phase 1 B (베타 품질) 추가분: +- draft/escalate 지연·오류율 → `/admin/metrics`의 `draft_*` / `escalate_*` 필드 (프로세스 메모리) +- `GET /admin/dashboard` — 최소 HTML 대시보드 +- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속) +- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차) ## 테스트 diff --git a/core-backend/b_routes.go b/core-backend/b_routes.go new file mode 100644 index 0000000..d099622 --- /dev/null +++ b/core-backend/b_routes.go @@ -0,0 +1,190 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type registerDeviceRequest struct { + Token string `json:"token" binding:"required"` + Platform string `json:"platform"` +} + +func messageJSON(m Message) gin.H { + return gin.H{ + "id": m.ID, + "conversation_id": m.ConversationID, + "sender_id": m.SenderID, + "sender_mode": m.SenderMode, + "text": m.Text, + "retracted": m.Retracted, + "created_at": m.CreatedAt, + } +} + +func registerBRoutes(r *gin.Engine, db *gorm.DB) { + // Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics. + r.GET("/admin/dashboard", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, adminDashboardHTML) + }) + + r.POST("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var req registerDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + platform := req.Platform + if platform == "" { + platform = "android" + } + var existing DeviceToken + err := db.Where("token = ?", req.Token).First(&existing).Error + if err == nil { + existing.UserID = userID + existing.Platform = platform + existing.UpdatedAt = time.Now() + db.Save(&existing) + c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform}) + return + } + row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform} + if err := db.Create(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform}) + }) + + r.GET("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var tokens []DeviceToken + db.Where("user_id = ?", userID).Order("id desc").Find(&tokens) + out := make([]gin.H, 0, len(tokens)) + for _, t := range tokens { + out = append(out, gin.H{ + "id": t.ID, + "platform": t.Platform, + "token_tail": trimToken(t.Token), + "updated_at": t.UpdatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"device_tokens": out}) + }) + + // Multi-device awareness: list active sessions for the authenticated user. + r.GET("/users/:id/sessions", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + actor, ok := currentUser(c, db, true) + if !ok { + return + } + if actor.ID != userID { + c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"}) + return + } + var sessions []Session + db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions) + out := make([]gin.H, 0, len(sessions)) + current := bearerToken(c) + for _, s := range sessions { + out = append(out, gin.H{ + "id": s.ID, + "created_at": s.CreatedAt, + "expires_at": s.ExpiresAt, + "is_current": s.Token == current, + }) + } + c.JSON(http.StatusOK, gin.H{"sessions": out}) + }) +} + +func trimToken(token string) string { + if len(token) <= 8 { + return "****" + } + return "…" + token[len(token)-6:] +} + +const adminDashboardHTML = ` + +
+ + +Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.
+ + +loading…+ + + +` \ No newline at end of file diff --git a/core-backend/b_test.go b/core-backend/b_test.go new file mode 100644 index 0000000..539d1fe --- /dev/null +++ b/core-backend/b_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func TestMessageCreateReturnsFullPayload(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "full-msg") + peerID, _ := mustSignup(t, server.URL, "full-msg-peer") + + convResp := postJSONAuth(t, server.URL+"/conversations", token, createConversationRequest{ + UserIDs: []uint{userID, peerID}, + }) + if convResp.StatusCode != http.StatusOK { + t.Fatalf("create conversation: %d", convResp.StatusCode) + } + var conv map[string]interface{} + json.NewDecoder(convResp.Body).Decode(&conv) + convID := uint(conv["id"].(float64)) + + msgResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: userID, + Text: "hello full", + }) + if msgResp.StatusCode != http.StatusOK { + t.Fatalf("send: %d", msgResp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(msgResp.Body).Decode(&out) + for _, key := range []string{"id", "conversation_id", "sender_id", "sender_mode", "text", "retracted"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing key %s in %v", key, out) + } + } + if out["text"] != "hello full" || out["sender_mode"] != "human" { + t.Fatalf("unexpected payload %v", out) + } +} + +func TestDeviceTokenAndSessions(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "devices") + + reg := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/device-tokens", token, registerDeviceRequest{ + Token: "fcm-test-token-abc", + Platform: "android", + }) + if reg.StatusCode != http.StatusOK { + t.Fatalf("device token: %d", reg.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var sout map[string]interface{} + json.NewDecoder(resp.Body).Decode(&sout) + sessions := sout["sessions"].([]interface{}) + if len(sessions) < 1 { + t.Fatalf("expected at least one session, got %v", sout) + } + first := sessions[0].(map[string]interface{}) + if first["is_current"] != true { + t.Fatalf("expected current session flag, got %v", first) + } +} + +func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) { + server, _ := setupTestServer(t) + req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil) + req.Header.Set("Authorization", "Bearer test-admin-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + for _, key := range []string{"draft_requests", "draft_error_rate", "draft_latency_avg_ms", "escalate_error_rate"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing metrics key %s in %v", key, out) + } + } +} diff --git a/core-backend/main.go b/core-backend/main.go index e13a15e..a7409e0 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -57,6 +57,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi }) registerA1A2Routes(r, db) + registerBRoutes(r, db) r.POST("/invites", func(c *gin.Context) { if !requireAdmin(c) { @@ -110,6 +111,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) } + rt := runtimeMetrics.snapshot() c.JSON(http.StatusOK, gin.H{ "users_total": usersTotal, "messages_human_total": humanMessages, @@ -123,7 +125,18 @@ 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, - "invites_minted": invitesMinted, + // Process-local draft/AI timings (roadmap B). Reset on restart. + "draft_requests": rt.DraftRequests, + "draft_errors": rt.DraftErrors, + "draft_error_rate": rt.DraftErrorRate, + "draft_latency_avg_ms": rt.DraftLatencyAvgMs, + "draft_latency_max_ms": rt.DraftLatencyMaxMs, + "draft_latency_samples": rt.DraftLatencySamples, + "escalate_checks": rt.EscalateChecks, + "escalate_errors": rt.EscalateErrors, + "escalate_error_rate": rt.EscalateErrorRate, + "twin_sends_blocked": rt.TwinSendsBlocked, + "invites_minted": invitesMinted, "invites_used": invitesUsed, }) }) @@ -207,16 +220,20 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // none of the later checks can override an earlier block. if req.SenderMode == SenderTwin { if conversation.TwinDisabledByPeer { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) return } result, err := ai.checkEscalation(req.Text) + runtimeMetrics.recordEscalate(err) if err != nil { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) return } if result.Escalate { + runtimeMetrics.recordTwinBlocked() db.Create(&EscalationLog{ UserID: req.SenderID, ConversationID: convID, @@ -237,19 +254,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi switch level { case AutonomyL0: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) return case AutonomyL1: if !req.Approved { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) return } case AutonomyL2: if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) return } default: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) return } @@ -274,7 +295,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi "text": message.Text, }) - c.JSON(http.StatusOK, gin.H{"id": message.ID}) + // Return the full message so Flutter can render without waiting on WS. + c.JSON(http.StatusOK, messageJSON(message)) }) r.POST("/messages/:id/retract", func(c *gin.Context) { @@ -337,16 +359,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } - // EscalationLog persistence (sender's post-hoc notification + undo - // trail) is a separate checklist item -- roadmap.md Phase 1 §2.2 - // "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies - // to the AI service for now. + started := time.Now() result, err := ai.requestDraft(draftRequest{ ContextLines: req.ContextLines, StyleExamples: req.StyleExamples, History: req.History, K: req.K, }) + runtimeMetrics.recordDraft(time.Since(started), err) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) return diff --git a/core-backend/metrics.go b/core-backend/metrics.go new file mode 100644 index 0000000..f196800 --- /dev/null +++ b/core-backend/metrics.go @@ -0,0 +1,103 @@ +package main + +import ( + "sync" + "time" +) + +// RuntimeMetrics tracks draft-generation latency and AI error rates in process +// memory (roadmap B). Survives for the life of the process; not a substitute +// for a time-series DB, but enough for a minimal admin dashboard. +type RuntimeMetrics struct { + mu sync.Mutex + + DraftRequests int64 + DraftErrors int64 + DraftLatencies []time.Duration // capped ring of recent samples + EscalateChecks int64 + EscalateErrors int64 + TwinSendsBlocked int64 +} + +const maxLatencySamples = 200 + +var runtimeMetrics = &RuntimeMetrics{} + +func (m *RuntimeMetrics) recordDraft(latency time.Duration, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.DraftRequests++ + if err != nil { + m.DraftErrors++ + } + m.DraftLatencies = append(m.DraftLatencies, latency) + if len(m.DraftLatencies) > maxLatencySamples { + m.DraftLatencies = m.DraftLatencies[len(m.DraftLatencies)-maxLatencySamples:] + } +} + +func (m *RuntimeMetrics) recordEscalate(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.EscalateChecks++ + if err != nil { + m.EscalateErrors++ + } +} + +func (m *RuntimeMetrics) recordTwinBlocked() { + m.mu.Lock() + defer m.mu.Unlock() + m.TwinSendsBlocked++ +} + +func (m *RuntimeMetrics) snapshot() ginHMetrics { + m.mu.Lock() + defer m.mu.Unlock() + + var sum time.Duration + var max time.Duration + for _, d := range m.DraftLatencies { + sum += d + if d > max { + max = d + } + } + var avgMs float64 + if n := len(m.DraftLatencies); n > 0 { + avgMs = float64(sum.Milliseconds()) / float64(n) + } + var errRate float64 + if m.DraftRequests > 0 { + errRate = float64(m.DraftErrors) / float64(m.DraftRequests) + } + var escErrRate float64 + if m.EscalateChecks > 0 { + escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks) + } + return ginHMetrics{ + DraftRequests: m.DraftRequests, + DraftErrors: m.DraftErrors, + DraftErrorRate: errRate, + DraftLatencyAvgMs: avgMs, + DraftLatencyMaxMs: float64(max.Milliseconds()), + DraftLatencySamples: len(m.DraftLatencies), + EscalateChecks: m.EscalateChecks, + EscalateErrors: m.EscalateErrors, + EscalateErrorRate: escErrRate, + TwinSendsBlocked: m.TwinSendsBlocked, + } +} + +type ginHMetrics struct { + DraftRequests int64 + DraftErrors int64 + DraftErrorRate float64 + DraftLatencyAvgMs float64 + DraftLatencyMaxMs float64 + DraftLatencySamples int + EscalateChecks int64 + EscalateErrors int64 + EscalateErrorRate float64 + TwinSendsBlocked int64 +} diff --git a/core-backend/models.go b/core-backend/models.go index 47ec75b..c18e58e 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -119,6 +119,17 @@ type EscalationLog struct { CreatedAt time.Time } +// DeviceToken stores an FCM registration token for push (roadmap B). +// Sending pushes is wired later; v1 persists tokens per user/device. +type DeviceToken struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + Token string `gorm:"uniqueIndex;not null"` + Platform string `gorm:"not null;default:android"` + CreatedAt time.Time + UpdatedAt time.Time +} + var allModels = []interface{}{ &User{}, &Session{}, @@ -130,4 +141,5 @@ var allModels = []interface{}{ &TwinSettings{}, &WhitelistRule{}, &EscalationLog{}, + &DeviceToken{}, } diff --git a/docs/roadmap.md b/docs/roadmap.md index da60372..cd270c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,8 +147,8 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다. 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 -4. [~] 2.3 Flutter 클라이언트 — A3까지: 가입·말투 온보딩 뼈대·대화/연락처 API 연동·히스토리· - L1 승인 UX·사후 알림 함·뱃지·거부권·되돌리기·자율성. 남은 것: 수동 E2E QA, drift+SQLCipher(B) +4. [~] 2.3 Flutter 클라이언트 — A3까지 완료 + API E2E(`scripts/e2e_a3.py`). 남은 것: B + (drift+SQLCipher·FCM 등), Android UI 탭은 실기기에서 `mobile/README.md` 체크리스트로 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 @@ -180,17 +180,22 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase - [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI) - [x] L1 승인 플로우 UX 정리 - [x] 사후 알림 함 -- [ ] 에뮬레이터/실기기 E2E 수동 QA +- [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리· + draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test` + 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지 ##### B. 그다음 — 베타 품질 -- [ ] FCM 푸시 연동 -- [ ] 멀티 디바이스 동기화 -- [ ] drift + SQLCipher 로컬 저장 -- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송 -- [ ] 데이터 흐름 표시 UI -- [ ] 생성 지연시간·오류율 계측 -- [ ] 모니터링 대시보드(최소) -- [ ] 본인확인 응답 문구 고정/검증 +- [~] FCM 푸시 연동 — 디바이스 토큰 등록 API(`POST /users/:id/device-tokens`)까지. + 실제 FCM 전송은 Firebase 프로젝트 키 연동 후 +- [~] 멀티 디바이스 동기화 — 활성 세션 목록(`GET /users/:id/sessions`) + Flutter 화면. + 강제 로그아웃·메시지 동기화 고도화는 후속 +- [ ] drift + SQLCipher 로컬 저장 — 다음 슬라이스(코드젠). 현재 말투는 SharedPreferences +- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — 온보딩 샘플 로컬 저장, draft에 샘플만 전달, + 데이터 흐름 UI로 원칙 노출 +- [x] 데이터 흐름 표시 UI — `DataFlowScreen` +- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics` +- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard` +- [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트 ##### C. 베타 직전 - [ ] Android 릴리즈 빌드·서명·배포 경로 diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart index b718537..9954a05 100644 --- a/mobile/lib/screens/autonomy_settings_screen.dart +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../state/session_state.dart'; +import 'data_flow_screen.dart'; +import 'sessions_screen.dart'; class AutonomySettingsScreen extends StatefulWidget { const AutonomySettingsScreen({super.key}); @@ -56,6 +58,25 @@ class _AutonomySettingsScreenState extends State