Start Phase 1 B: metrics, identity phrase, data-flow, sessions
- Record draft/escalate latency and error rates on /admin/metrics - Add minimal /admin/dashboard and full message JSON on send - Fix identity answers in ai-service with stable copy + tests - Flutter DataFlowScreen + SessionsScreen; device-token API stub - Update roadmap B checkboxes after A3 API E2E Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
7dc4216dad
commit
a51fc1490f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- 사용자 스코프 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)
|
||||
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,6 +125,17 @@ 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,
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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{},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 릴리즈 빌드·서명·배포 경로
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue