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"`
|
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 := gin.Default()
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
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})
|
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) {
|
r.GET("/ws/conversations/:id", func(c *gin.Context) {
|
||||||
convID, ok := parseUintParam(c, "id")
|
convID, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -133,6 +180,7 @@ func parseUintParam(c *gin.Context, name string) (uint, bool) {
|
||||||
func main() {
|
func main() {
|
||||||
db := openDB()
|
db := openDB()
|
||||||
relay := newConnectionManager()
|
relay := newConnectionManager()
|
||||||
r := setupRouter(db, relay)
|
ai := newAIServiceClient()
|
||||||
|
r := setupRouter(db, relay, ai)
|
||||||
r.Run(":8080")
|
r.Run(":8080")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,28 @@ import (
|
||||||
"gorm.io/gorm"
|
"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) {
|
func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dbPath := t.TempDir() + "/test.db"
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
|
@ -27,8 +49,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := setupRouter(db, newConnectionManager())
|
router := setupRouter(db, newConnectionManager(), ai)
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
t.Cleanup(server.Close)
|
t.Cleanup(server.Close)
|
||||||
return server, db
|
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) {
|
func TestMain(m *testing.M) {
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@
|
||||||
- [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는
|
- [x] `poc/tone-corpus/generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`를 감싸는
|
||||||
FastAPI 서비스로 승격 — `ai-service/` (`POST /draft`, style_examples/history 두 경로 +
|
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) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기
|
- [ ] 자율성 엔진(L0~L2) 오케스트레이션: 에스컬레이션 게이트 → 검색 → 초안 생성 → 승인/자동발송 분기
|
||||||
(`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디
|
(`tech-design.md` §3 흐름 그대로) — 이 오케스트레이션이 Go 코어와 Python AI 서비스 중 어디
|
||||||
책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음)
|
책임인지는 구현 시작 시 정할 것 (에스컬레이션 하드게이트는 Go 코어에 두는 게 안전선 원칙상 더 맞을 수 있음)
|
||||||
|
|
@ -95,9 +97,12 @@
|
||||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음).
|
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음).
|
||||||
병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이
|
병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이
|
||||||
불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작
|
불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작
|
||||||
3. [~] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어에서 실제 호출하는 연동 코드는 아직 —
|
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`,
|
||||||
**다음 작업**. 2.3 Flutter는 여전히 로컬 환경 대기 중
|
`POST /conversations/:id/draft`)도 완료·테스트 통과. 남은 건 자율성 엔진 오케스트레이션, 온디바이스
|
||||||
4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지)
|
말투 이력 저장, 사후 알림/되돌리기 로그 — 이건 2.4/2.5와 겹치므로 그쪽에서 이어감. 2.3 Flutter는
|
||||||
|
여전히 로컬 환경 대기 중
|
||||||
|
4. [ ] 2.3 나머지 UX(온보딩·설정·뱃지) — **다음 작업 대상은 이 항목이지만 Flutter SDK가 없는 이
|
||||||
|
환경에서는 착수 불가. 2.4(안전장치 통합)으로 순서를 당겨 진행**
|
||||||
5. [ ] 2.4/2.5 안전장치·QA
|
5. [ ] 2.4/2.5 안전장치·QA
|
||||||
6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈
|
6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 베타 오픈
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue