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 pathlib import Path
from .escalation_filter import check as check_escalation from .escalation_filter import check as check_escalation
from .identity import check_identity_question
def load_dotenv_if_present(): def load_dotenv_if_present():
@ -39,6 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \ - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 문장만 출력한다: \
[ESCALATE] 내용은 본인 확인이 필요합니다. [ESCALATE] 내용은 본인 확인이 필요합니다.
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \
(서버가 고정 문구로 먼저 처리하지만, 여기까지 경우에도 분신이라고 답한다).
( 지침은 2 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 ( 지침은 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). "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft. "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: if gate.escalate:
return "escalate", gate.reason 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") api_key = api_key or os.environ.get("GEMINI_API_KEY")
if not api_key: if not api_key:
return "no_key", build_user_prompt(style_examples, context_lines) 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) - 가입 `POST /auth/signup` / 로그인 `POST /auth/login``{token}` (Bearer)
- 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 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) registerA1A2Routes(r, db)
registerBRoutes(r, db)
r.POST("/invites", func(c *gin.Context) { r.POST("/invites", func(c *gin.Context) {
if !requireAdmin(c) { if !requireAdmin(c) {
@ -110,6 +111,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
} }
rt := runtimeMetrics.snapshot()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users_total": usersTotal, "users_total": usersTotal,
"messages_human_total": humanMessages, "messages_human_total": humanMessages,
@ -123,6 +125,17 @@ 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,
// 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_minted": invitesMinted,
"invites_used": invitesUsed, "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. // none of the later checks can override an earlier block.
if req.SenderMode == SenderTwin { if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer { if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return return
} }
result, err := ai.checkEscalation(req.Text) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return return
} }
if result.Escalate { if result.Escalate {
runtimeMetrics.recordTwinBlocked()
db.Create(&EscalationLog{ db.Create(&EscalationLog{
UserID: req.SenderID, UserID: req.SenderID,
ConversationID: convID, ConversationID: convID,
@ -237,19 +254,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
switch level { switch level {
case AutonomyL0: case AutonomyL0:
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return return
case AutonomyL1: case AutonomyL1:
if !req.Approved { if !req.Approved {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
return return
} }
case AutonomyL2: case AutonomyL2:
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
return return
} }
default: default:
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
return return
} }
@ -274,7 +295,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
"text": message.Text, "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) { r.POST("/messages/:id/retract", func(c *gin.Context) {
@ -337,16 +359,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
// EscalationLog persistence (sender's post-hoc notification + undo started := time.Now()
// trail) is a separate checklist item -- roadmap.md Phase 1 §2.2
// "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies
// to the AI service for now.
result, err := ai.requestDraft(draftRequest{ result, err := ai.requestDraft(draftRequest{
ContextLines: req.ContextLines, ContextLines: req.ContextLines,
StyleExamples: req.StyleExamples, StyleExamples: req.StyleExamples,
History: req.History, History: req.History,
K: req.K, K: req.K,
}) })
runtimeMetrics.recordDraft(time.Since(started), err)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return 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 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{}{ var allModels = []interface{}{
&User{}, &User{},
&Session{}, &Session{},
@ -130,4 +141,5 @@ var allModels = []interface{}{
&TwinSettings{}, &TwinSettings{},
&WhitelistRule{}, &WhitelistRule{},
&EscalationLog{}, &EscalationLog{},
&DeviceToken{},
} }

View File

@ -147,8 +147,8 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
4. [~] 2.3 Flutter 클라이언트 — A3까지: 가입·말투 온보딩 뼈대·대화/연락처 API 연동·히스토리· 4. [~] 2.3 Flutter 클라이언트 — A3까지 완료 + API E2E(`scripts/e2e_a3.py`). 남은 것: B
L1 승인 UX·사후 알림 함·뱃지·거부권·되돌리기·자율성. 남은 것: 수동 E2E QA, drift+SQLCipher(B) (drift+SQLCipher·FCM 등), Android UI 탭은 실기기에서 `mobile/README.md` 체크리스트로
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
@ -180,17 +180,22 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI) - [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
- [x] L1 승인 플로우 UX 정리 - [x] L1 승인 플로우 UX 정리
- [x] 사후 알림 함 - [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. 그다음 — 베타 품질 ##### B. 그다음 — 베타 품질
- [ ] FCM 푸시 연동 - [~] FCM 푸시 연동 — 디바이스 토큰 등록 API(`POST /users/:id/device-tokens`)까지.
- [ ] 멀티 디바이스 동기화 실제 FCM 전송은 Firebase 프로젝트 키 연동 후
- [ ] drift + SQLCipher 로컬 저장 - [~] 멀티 디바이스 동기화 — 활성 세션 목록(`GET /users/:id/sessions`) + Flutter 화면.
- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송 강제 로그아웃·메시지 동기화 고도화는 후속
- [ ] 데이터 흐름 표시 UI - [ ] 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. 베타 직전 ##### C. 베타 직전
- [ ] Android 릴리즈 빌드·서명·배포 경로 - [ ] 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 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. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에) 1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
2. 연락처에 상대 사용자 ID 등록 → 대화 시작 2. 연락처에 상대 사용자 ID 등록 → 대화 시작

View File

@ -4,6 +4,8 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'data_flow_screen.dart';
import 'sessions_screen.dart';
class AutonomySettingsScreen extends StatefulWidget { class AutonomySettingsScreen extends StatefulWidget {
const AutonomySettingsScreen({super.key}); const AutonomySettingsScreen({super.key});
@ -56,6 +58,25 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ 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), Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
SegmentedButton<AutonomyLevel>( 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 { Future<void> deleteWhitelist(int userId, int ruleId) async {
await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId'); 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())