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:
commit
6b06f1fde6
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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차)
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
`
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 릴리즈 빌드·서명·배포 경로
|
||||||
|
|
|
||||||
|
|
@ -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 등록 → 대화 시작
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'] ?? ''}'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
Loading…
Reference in New Issue