Merge pull request #7 from o0kuma/cursor/e2e-then-phase-b-382d

A3 E2E + Phase 1 B foundations (metrics, identity, data-flow)
This commit is contained in:
cursor[bot] 2026-07-30 05:23:33 +00:00 committed by GitHub
commit 6b06f1fde6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 907 additions and 20 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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차)
## 테스트

190
core-backend/b_routes.go Normal file
View File

@ -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 = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>분신 · admin metrics</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
body { margin: 0; padding: 24px; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
p { color: #40554c; margin: 0 0 20px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.card { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; }
.k { font-size: .75rem; color: #5b7267; text-transform: uppercase; letter-spacing: .04em; }
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>분신 운영 지표</h1>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div>
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
<pre id="raw">loading</pre>
<script>
async function load() {
const params = new URLSearchParams(location.search);
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
if (!token) {
token = prompt('ADMIN_API_TOKEN') || '';
if (token) localStorage.ADMIN_API_TOKEN = token;
} else if (params.get('token')) {
localStorage.ADMIN_API_TOKEN = token;
}
const r = await fetch('/admin/metrics', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} });
const data = await r.json();
document.getElementById('raw').textContent = JSON.stringify(data, null, 2);
const cards = [
['users', data.users_total],
['human msgs', data.messages_human_total],
['twin msgs', data.messages_twin_total],
['escalations', data.escalations_total],
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
['draft req', data.draft_requests],
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
['draft max ms', (data.draft_latency_max_ms || 0).toFixed(1)],
['escalate err rate', (data.escalate_error_rate || 0).toFixed(3)],
];
document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
}
load();
</script>
</body>
</html>
`

91
core-backend/b_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -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

103
core-backend/metrics.go Normal file
View File

@ -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
}

View File

@ -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{},
}

View File

@ -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 릴리즈 빌드·서명·배포 경로

View File

@ -29,7 +29,20 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080
```
## E2E 수동 QA 체크 (에뮬레이터/실기기)
## E2E
**API 레벨 (클라우드/CI에서 실행 가능)**
```bash
# core-backend :8080 + ai-service :8001 기동 후
export ADMIN_API_TOKEN=dev-admin-token
python3 scripts/e2e_a3.py
```
A3 체크리스트(가입·연락처·대화·히스토리·draft/L1·에스컬레이션·되돌리기·거부권·화이트리스트)를
HTTP로 검증한다.
**에뮬레이터/실기기 UI 탭 (Android SDK 필요)**
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
2. 연락처에 상대 사용자 ID 등록 → 대화 시작

View File

@ -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<AutonomySettingsScreen> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('데이터 흐름'),
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.devices),
title: const Text('로그인 세션'),
subtitle: const Text('멀티 디바이스 세션 목록'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
},
),
const Divider(height: 32),
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SegmentedButton<AutonomyLevel>(

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
/// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
class DataFlowScreen extends StatelessWidget {
const DataFlowScreen({super.key});
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final theme = Theme.of(context);
final samples = session.styleExamples;
return Scaffold(
appBar: AppBar(title: const Text('데이터 흐름')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Card(
child: ListTile(
title: Text('말투 샘플 ${samples.length}'),
subtitle: Text(samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · ')),
),
),
const SizedBox(height: 24),
Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
'채팅 릴레이: 보낸 메시지 본문.\n'
'계정: 표시 이름·초대 코드·세션 토큰.\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
/// Multi-device awareness: list active sessions (roadmap B first step).
class SessionsScreen extends StatefulWidget {
const SessionsScreen({super.key});
@override
State<SessionsScreen> createState() => _SessionsScreenState();
}
class _SessionsScreenState extends State<SessionsScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
if (session.user == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final list = await session.api.listSessions(session.user!.id);
setState(() => _sessions = list);
} on ApiException catch (e) {
setState(() => _error = '세션 목록 실패 (${e.statusCode})');
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('로그인 세션')),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
: ListView(
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text('이 계정에 연결된 활성 세션입니다. 기기별 강제 로그아웃은 이후 단계에서 추가합니다.'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
for (final s in _sessions)
ListTile(
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
),
],
),
),
);
}
}

View File

@ -197,4 +197,21 @@ class ApiClient {
Future<void> deleteWhitelist(int userId, int ruleId) async {
await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId');
}
Future<List<Map<String, dynamic>>> listSessions(int userId) async {
final obj = await _getObject('/users/$userId/sessions');
final list = (obj['sessions'] as List<dynamic>? ?? const []);
return list.cast<Map<String, dynamic>>();
}
Future<void> registerDeviceToken({
required int userId,
required String token,
String platform = 'android',
}) async {
await _json('POST', '/users/$userId/device-tokens', body: {
'token': token,
'platform': platform,
});
}
}

197
scripts/e2e_a3.py Executable file
View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""A3 API-level E2E against a running core-backend (+ ai-service for draft/escalate).
Covers mobile/README.md checklist at the HTTP layer.
Android emulator/device UI taps are out of scope in this cloud VM (no Android SDK).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
BASE = os.environ.get("CORE_API_BASE", "http://127.0.0.1:8080")
ADMIN = os.environ.get("ADMIN_API_TOKEN", "dev-admin-token")
passed = 0
failed = 0
def ok(msg: str) -> None:
global passed
passed += 1
print(f" OK: {msg}")
def fail(msg: str) -> None:
global failed
failed += 1
print(f" FAIL: {msg}")
def step(msg: str) -> None:
print(f"\n==> {msg}")
def req(method: str, path: str, body=None, token: str | None = None, admin: bool = False):
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if admin:
headers["Authorization"] = f"Bearer {ADMIN}"
elif token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as resp:
raw = resp.read().decode()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
payload = {"raw": raw}
return e.code, payload
def main() -> int:
step("health")
code, body = req("GET", "/health")
ok("core health") if code == 200 else fail(f"health {code} {body}")
step("1) invite + signup (two users)")
_, ia = req("POST", "/invites", {}, admin=True)
_, ib = req("POST", "/invites", {}, admin=True)
code_a, sa = req("POST", "/auth/signup", {"invite_code": ia["code"], "display_name": "E2E Alice"})
code_b, sb = req("POST", "/auth/signup", {"invite_code": ib["code"], "display_name": "E2E Bob"})
if code_a == 200 and code_b == 200 and sa.get("token") and sb.get("token"):
ok(f"signup tokens for #{sa['id']} and #{sb['id']}")
else:
fail(f"signup a={code_a} b={code_b}")
return 1
token_a, token_b = sa["token"], sb["token"]
id_a, id_b = sa["id"], sb["id"]
step("2) contact + conversation create")
_, contact = req(
"POST",
f"/users/{id_a}/contacts",
{"display_name": "Bob", "contact_user_id": id_b, "relationship_note": "e2e"},
token=token_a,
)
contact_id = contact["id"]
_, conv = req(
"POST",
"/conversations",
{"user_ids": [id_a, id_b], "contact_id": contact_id},
token=token_a,
)
conv_id = conv["id"]
ok(f"conversation #{conv_id} via contact #{contact_id}")
_, clist = req("GET", "/conversations", token=token_a)
ids = [c["id"] for c in clist.get("conversations", [])]
ok("conversation list includes room") if conv_id in ids else fail(f"list missing room: {ids}")
step("3) human message + history")
_, msg = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "안녕 Bob", "sender_mode": "human"},
token=token_a,
)
msg_id = msg["id"]
_, hist = req("GET", f"/conversations/{conv_id}/messages", token=token_a)
hist_ids = [m["id"] for m in hist.get("messages", [])]
ok(f"history retains message #{msg_id}") if msg_id in hist_ids else fail("history")
step("4) draft + L1 approved twin send")
dcode, draft = req(
"POST",
f"/conversations/{conv_id}/draft",
{"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅇ 알겠음", "ㅋㅋ 그래"]},
token=token_a,
)
if dcode == 200 and draft.get("status") in {"ok", "no_key", "escalate"}:
ok(f"draft status={draft.get('status')}")
else:
fail(f"draft {dcode} {draft}")
req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L1"}, token=token_a)
twin_text = "ㅇㅇ 알겠음 나중에"
if draft.get("status") == "ok" and draft.get("text"):
twin_text = draft["text"]
tcode, twin = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": twin_text, "sender_mode": "twin", "approved": True},
token=token_a,
)
twin_id = twin.get("id")
if tcode == 200 and twin_id:
ok(f"L1 approved twin message #{twin_id}")
else:
fail(f"twin send {tcode} {twin}")
return 1
step("5) escalation path + inbox logs")
ecode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{
"sender_id": id_a,
"text": "계좌로 돈 보내줄게 계좌번호 알려줘",
"sender_mode": "twin",
"approved": True,
},
token=token_a,
)
if ecode != 200:
ok(f"escalating twin blocked (HTTP {ecode})")
else:
fail("escalating twin unexpectedly accepted")
_, logs = req("GET", f"/users/{id_a}/escalation-logs", token=token_a)
if "escalation_logs" in logs:
ok(f"escalation-logs count={len(logs['escalation_logs'])}")
else:
fail("escalation-logs shape")
step("6) retract twin + veto")
rcode, _ = req("POST", f"/messages/{twin_id}/retract", token=token_a)
ok(f"retract HTTP {rcode}") if rcode == 200 else fail(f"retract {rcode}")
vcode, _ = req("POST", f"/conversations/{conv_id}/veto", token=token_b)
ok(f"peer veto HTTP {vcode}") if vcode == 200 else fail(f"veto {vcode}")
bcode, _ = req(
"POST",
f"/conversations/{conv_id}/messages",
{"sender_id": id_a, "text": "veto 이후", "sender_mode": "twin", "approved": True},
token=token_a,
)
ok(f"twin blocked after veto (HTTP {bcode})") if bcode != 200 else fail("twin allowed after veto")
step("7) whitelist CRUD + autonomy")
_, wl = req(
"POST",
f"/users/{id_a}/whitelist-rules",
{"topic_keyword": "날씨"},
token=token_a,
)
_, wlist = req("GET", f"/users/{id_a}/whitelist-rules", token=token_a)
rules = wlist.get("whitelist_rules", [])
ok("whitelist add/list") if any(r.get("topic_keyword") == "날씨" for r in rules) else fail("whitelist")
dcode, _ = req("DELETE", f"/users/{id_a}/whitelist-rules/{wl['id']}", token=token_a)
ok("whitelist delete") if dcode == 200 else fail(f"whitelist delete {dcode}")
acode, _ = req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L0"}, token=token_a)
ok("autonomy L0 reset") if acode == 200 else fail(f"autonomy {acode}")
step("8) contacts list")
_, contacts = req("GET", f"/users/{id_a}/contacts", token=token_a)
cids = [c["id"] for c in contacts.get("contacts", [])]
ok("contacts list") if contact_id in cids else fail("contacts list")
print(f"\n==== E2E RESULT: {passed} passed, {failed} failed ====")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())