diff --git a/core-backend/aiservice.go b/core-backend/aiservice.go new file mode 100644 index 0000000..b3659cb --- /dev/null +++ b/core-backend/aiservice.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +// AIServiceClient calls the Python AI service's POST /draft (ai-service/, +// tech-design.md §8's "Go 코어 → Python AI 서비스, 내부망 HTTP"). +type AIServiceClient struct { + BaseURL string + HTTP *http.Client +} + +func newAIServiceClient() *AIServiceClient { + return &AIServiceClient{BaseURL: aiServiceURL(), HTTP: http.DefaultClient} +} + +type draftRequest struct { + ContextLines []string `json:"context_lines"` + StyleExamples []string `json:"style_examples,omitempty"` + History []string `json:"history,omitempty"` + K int `json:"k,omitempty"` +} + +type draftResponse struct { + Status string `json:"status"` + Text string `json:"text"` +} + +func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := c.HTTP.Post(c.BaseURL+"/draft", "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ai service unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ai service returned %d", resp.StatusCode) + } + + var out draftResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/core-backend/main.go b/core-backend/main.go index d9705ea..8208afd 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -24,7 +24,14 @@ type sendMessageRequest struct { SenderMode SenderMode `json:"sender_mode"` } -func setupRouter(db *gorm.DB, relay *ConnectionManager) *gin.Engine { +type draftMessageRequest struct { + ContextLines []string `json:"context_lines" binding:"required"` + StyleExamples []string `json:"style_examples"` + History []string `json:"history"` + K int `json:"k"` +} + +func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gin.Engine { r := gin.Default() r.GET("/health", func(c *gin.Context) { @@ -96,6 +103,46 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager) *gin.Engine { c.JSON(http.StatusOK, gin.H{"id": message.ID}) }) + r.POST("/conversations/:id/draft", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + var req draftMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if len(req.StyleExamples) == 0 && len(req.History) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "provide style_examples or history"}) + 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. + result, err := ai.requestDraft(draftRequest{ + ContextLines: req.ContextLines, + StyleExamples: req.StyleExamples, + History: req.History, + K: req.K, + }) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": result.Status, "text": result.Text}) + }) + r.GET("/ws/conversations/:id", func(c *gin.Context) { convID, ok := parseUintParam(c, "id") if !ok { @@ -133,6 +180,7 @@ func parseUintParam(c *gin.Context, name string) (uint, bool) { func main() { db := openDB() relay := newConnectionManager() - r := setupRouter(db, relay) + ai := newAIServiceClient() + r := setupRouter(db, relay, ai) r.Run(":8080") } diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 9a2bdaf..b087f8b 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -16,6 +16,28 @@ import ( "gorm.io/gorm" ) +// mockAIService stands in for ai-service/ during tests -- it echoes back +// a canned response so core-backend's HTTP client code (not the Python +// service itself) is what's under test here. +func mockAIService(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/draft" { + w.WriteHeader(http.StatusNotFound) + return + } + var req draftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")}) + })) + t.Cleanup(server.Close) + return server +} + func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) { t.Helper() dbPath := t.TempDir() + "/test.db" @@ -27,8 +49,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) { t.Fatalf("migrate: %v", err) } + ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient} + gin.SetMode(gin.TestMode) - router := setupRouter(db, newConnectionManager()) + router := setupRouter(db, newConnectionManager(), ai) server := httptest.NewServer(router) t.Cleanup(server.Close) return server, db @@ -121,6 +145,55 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) { } } +func TestDraftMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + StyleExamples: []string{"ㅇㅇ 좋지"}, + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestDraftRequiresStyleSource(t *testing.T) { + server, db := setupTestServer(t) + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestDraftProxiesToAIService(t *testing.T) { + server, db := setupTestServer(t) + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + + resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{ + ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"}, + StyleExamples: []string{"ㅇㅇ 좋지"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out draftResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Status != "ok" || !strings.Contains(out.Text, "오늘 저녁에 뭐 먹을래") { + t.Fatalf("unexpected draft response: %+v", out) + } +} + func TestMain(m *testing.M) { os.Exit(m.Run()) } diff --git a/docs/roadmap.md b/docs/roadmap.md index 7fc4137..804a087 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -46,7 +46,9 @@ - [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는 FastAPI 서비스로 승격 — `ai-service/` (`POST /draft`, style_examples/history 두 경로 + 에스컬레이션 하드게이트 + 검증 오류 전부 실제 테스트로 확인함) -- [ ] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) +- [x] Go 코어가 이 서비스를 실제로 호출하는 클라이언트 코드 (`core-backend/`에서 `AI_SERVICE_URL` 사용) + — `core-backend/aiservice.go`(`AIServiceClient.requestDraft`) + `POST /conversations/:id/draft` + 라우트, mock AI 서비스로 정상 프록시·404·400(스타일 소스 없음) 전부 실제 테스트로 확인함 - [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기 (`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디 책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음) @@ -95,9 +97,12 @@ 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 -3. [~] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어에서 실제 호출하는 연동 코드는 아직 — - **다음 작업**. 2.3 Flutter는 여전히 로컬 환경 대기 중 -4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) +3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, + `POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스 + 말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는 + 여전히 로컬 환경 대기 중 +4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) — **다음 작업 대상은 이 항목이지만 Flutter SDK가 없는 이 + 환경에서는 착수 불가. 2.4(안전장치 통합)으로 순서를 당겨 진행** 5. [ ] 2.4/2.5 안전장치·QA 6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈