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:
parent
e8cf48074f
commit
fb06306d04
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 베타 오픈
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue