Go 코어에서 AI 서비스를 실제로 호출하는 연동 코드 추가

AIServiceClient.requestDraft가 ai-service/의 POST /draft를 호출하고,
core-backend에 POST /conversations/:id/draft 라우트를 추가해 프록시한다.
mock AI 서비스로 정상 응답·404(대화 없음)·400(스타일 소스 없음) 케이스를
테스트로 확인. roadmap.md Phase 1 §2.2 체크리스트 반영.
This commit is contained in:
Claude 2026-07-30 02:15:32 +00:00
parent e8cf48074f
commit fb06306d04
No known key found for this signature in database
4 changed files with 187 additions and 7 deletions

54
core-backend/aiservice.go Normal file
View File

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

View File

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

View File

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

View File

@ -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 베타 오픈