Spaces:
Sleeping
Sleeping
| package api | |
| import ( | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "net/http" | |
| "strings" | |
| "project/internal/app" | |
| "project/internal/domain" | |
| "project/internal/framework" | |
| ) | |
| type HTTPHandler struct { | |
| Registry framework.Registry | |
| AppService *app.Service | |
| } | |
| func NewHTTPHandler(registry framework.Registry, appService *app.Service) *HTTPHandler { | |
| return &HTTPHandler{ | |
| Registry: registry, | |
| AppService: appService, | |
| } | |
| } | |
| // Health godoc | |
| // | |
| // @Summary Health check | |
| // @Description Returns holistic readiness and dependency health for the service. | |
| // @Tags system | |
| // @Produce json | |
| // @Success 200 {object} map[string]interface{} "Health report" | |
| // @Failure 503 {object} map[string]interface{} "Critical dependency unavailable" | |
| // @Router /health [get] | |
| func (h *HTTPHandler) Health(w http.ResponseWriter, r *http.Request) { | |
| writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) | |
| } | |
| // ListFrameworks godoc | |
| // | |
| // @Produce json | |
| // @Success 200 {array} object{id=string,name=string} | |
| func (h *HTTPHandler) ListFrameworks(w http.ResponseWriter, r *http.Request) { | |
| items := h.Registry.List() | |
| type item struct { | |
| ID string `json:"id"` | |
| Name string `json:"name"` | |
| } | |
| out := []item{} | |
| for _, fw := range items { | |
| out = append(out, item{ID: fw.Framework.ID, Name: fw.Framework.Name}) | |
| } | |
| writeJSON(w, http.StatusOK, out) | |
| } | |
| // CreateSession godoc | |
| // | |
| // @Accept json | |
| // @Produce json | |
| // @Param request body CreateSessionRequest true "Request body" | |
| // @Success 201 {object} CreateSessionResponse | |
| // @Failure 400 {object} ErrorResponse | |
| func (h *HTTPHandler) CreateSession(w http.ResponseWriter, r *http.Request) { | |
| var req CreateSessionRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeError(w, http.StatusBadRequest, "invalid request body") | |
| return | |
| } | |
| if req.FrameworkID == "" { | |
| writeError(w, http.StatusBadRequest, "framework_id is required") | |
| return | |
| } | |
| if req.UserID == "" { | |
| writeError(w, http.StatusBadRequest, "user_id is required") | |
| return | |
| } | |
| if req.RoomID == "" { | |
| writeError(w, http.StatusBadRequest, "room_id is required") | |
| return | |
| } | |
| if req.Language == "" { | |
| req.Language = "id-ID" | |
| } | |
| result, err := h.AppService.StartSession(r.Context(), req.FrameworkID, req.Language, req.UserID, req.RoomID, parseMode(req.Mode)) | |
| if err != nil { | |
| writeError(w, http.StatusBadRequest, err.Error()) | |
| return | |
| } | |
| writeJSON(w, http.StatusCreated, CreateSessionResponse{ | |
| SessionID: result.SessionID, | |
| FrameworkID: result.FrameworkID, | |
| RoomID: result.RoomID, | |
| Status: "active", | |
| OpeningMessage: result.OpeningMessage, | |
| FirstQuestion: result.FirstQuestion, | |
| }) | |
| } | |
| // SendMessage godoc | |
| // | |
| // @Accept json | |
| // @Produce json | |
| // @Param id path string true "Session ID" | |
| // @Param request body SendTextRequest true "Request body" | |
| // @Success 200 {object} SendTextResponse | |
| // @Failure 400 {object} ErrorResponse | |
| // @Failure 500 {object} ErrorResponse | |
| func (h *HTTPHandler) SendMessage(w http.ResponseWriter, r *http.Request) { | |
| sessionID := r.PathValue("id") | |
| if sessionID == "" { | |
| writeError(w, http.StatusBadRequest, "session_id is required") | |
| return | |
| } | |
| var req SendTextRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeError(w, http.StatusBadRequest, "invalid request body") | |
| return | |
| } | |
| if req.Message == "" { | |
| writeError(w, http.StatusBadRequest, "message is required") | |
| return | |
| } | |
| if strings.TrimSpace(req.Message) == "/finish" { | |
| h.handleFinishCommand(w, r, sessionID) | |
| return | |
| } | |
| result, err := h.AppService.SendMessage(r.Context(), sessionID, req.Message) | |
| if err != nil { | |
| writeError(w, http.StatusInternalServerError, err.Error()) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, SendTextResponse{Reply: result.Reply, Stage: result.Stage, Finished: result.Finished}) | |
| } | |
| // FinishSession godoc | |
| // | |
| // @Produce json | |
| // @Param id path string true "Session ID" | |
| // @Success 200 {object} FinishSessionResponse | |
| // @Failure 400 {object} ErrorResponse | |
| // @Failure 500 {object} ErrorResponse | |
| func (h *HTTPHandler) FinishSession(w http.ResponseWriter, r *http.Request) { | |
| sessionID := r.PathValue("id") | |
| if sessionID == "" { | |
| writeError(w, http.StatusBadRequest, "session_id is required") | |
| return | |
| } | |
| result, err := h.AppService.FinishSession(r.Context(), sessionID) | |
| if err != nil { | |
| writeError(w, http.StatusInternalServerError, err.Error()) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, FinishSessionResponse{ | |
| SessionID: sessionID, | |
| RoomID: result.RoomID, | |
| Status: "completed", | |
| Result: toInterviewResultDTO(result.Result), | |
| }) | |
| } | |
| // GetRoomSession godoc | |
| // | |
| // @Produce json | |
| // @Param room_id path string true "Room ID" | |
| // @Success 200 {object} RoomSessionResponse | |
| // @Failure 404 {object} ErrorResponse | |
| func (h *HTTPHandler) GetRoomSession(w http.ResponseWriter, r *http.Request) { | |
| roomID := r.PathValue("room_id") | |
| if roomID == "" { | |
| writeError(w, http.StatusBadRequest, "room_id is required") | |
| return | |
| } | |
| res, err := h.AppService.GetRoomSession(r.Context(), roomID) | |
| if err != nil { | |
| writeError(w, http.StatusNotFound, err.Error()) | |
| return | |
| } | |
| turns := make([]TurnDTO, 0, len(res.Turns)) | |
| for _, t := range res.Turns { | |
| turns = append(turns, TurnDTO{ | |
| Speaker: t.Speaker, | |
| Text: t.Text, | |
| CreatedAt: t.CreatedAt, | |
| }) | |
| } | |
| writeJSON(w, http.StatusOK, RoomSessionResponse{ | |
| SessionID: res.Session.SessionID, | |
| RoomID: res.Session.RoomID, | |
| FrameworkID: res.Session.FrameworkID, | |
| FrameworkName: res.Session.FrameworkName, | |
| Status: string(res.Session.Status), | |
| Mode: string(res.Session.Mode), | |
| Language: res.Session.Language, | |
| StartedAt: res.Session.StartedAt, | |
| Turns: turns, | |
| }) | |
| } | |
| // GetRoomResult godoc | |
| // | |
| // @Produce json | |
| // @Param room_id path string true "Room ID" | |
| // @Success 200 {object} InterviewResultDTO | |
| // @Failure 404 {object} ErrorResponse | |
| func (h *HTTPHandler) GetRoomResult(w http.ResponseWriter, r *http.Request) { | |
| roomID := r.PathValue("room_id") | |
| if roomID == "" { | |
| writeError(w, http.StatusBadRequest, "room_id is required") | |
| return | |
| } | |
| res, err := h.AppService.GetRoomResult(roomID) | |
| if err != nil { | |
| if errors.Is(err, app.ErrInterviewNotFinished) { | |
| writeError(w, http.StatusNotFound, err.Error()) | |
| } else { | |
| writeError(w, http.StatusInternalServerError, "failed to retrieve interview result") | |
| } | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, toInterviewResultDTO(res)) | |
| } | |
| func (h *HTTPHandler) handleFinishCommand(w http.ResponseWriter, r *http.Request, sessionID string) { | |
| result, err := h.AppService.FinishSession(r.Context(), sessionID) | |
| if err != nil { | |
| writeError(w, http.StatusInternalServerError, err.Error()) | |
| return | |
| } | |
| writeJSON(w, http.StatusOK, FinishSessionResponse{ | |
| SessionID: sessionID, | |
| RoomID: result.RoomID, | |
| Status: "completed", | |
| Result: toInterviewResultDTO(result.Result), | |
| }) | |
| } | |
| func toInterviewResultDTO(r *domain.InterviewResult) *InterviewResultDTO { | |
| if r == nil { | |
| return nil | |
| } | |
| sections := make([]SectionResultDTO, 0, len(r.SectionResults)) | |
| for _, s := range r.SectionResults { | |
| pairs := make([]QAPairDTO, 0, len(s.QAPairs)) | |
| for _, p := range s.QAPairs { | |
| pairs = append(pairs, toQAPairDTO(p)) | |
| } | |
| sections = append(sections, SectionResultDTO{ | |
| SectionTitle: s.SectionTitle, | |
| Objective: s.Objective, | |
| QAPairs: pairs, | |
| SectionSummary: s.SectionSummary, | |
| }) | |
| } | |
| return &InterviewResultDTO{ | |
| FrameworkName: r.FrameworkName, | |
| Mode: r.Mode, | |
| Language: r.Language, | |
| StartedAt: r.StartedAt, | |
| EndedAt: r.EndedAt, | |
| Summary: r.Summary, | |
| Goals: r.Goals, | |
| SectionResults: sections, | |
| KeyInsights: r.KeyInsights, | |
| UnresolvedItems: r.UnresolvedItems, | |
| } | |
| } | |
| func toQAPairDTO(p domain.QAPair) QAPairDTO { | |
| followUps := make([]QAPairDTO, 0, len(p.FollowUps)) | |
| for _, f := range p.FollowUps { | |
| followUps = append(followUps, toQAPairDTO(f)) | |
| } | |
| dto := QAPairDTO{ | |
| QuestionText: p.QuestionText, | |
| AnswerCleaned: p.AnswerCleaned, | |
| } | |
| if len(followUps) > 0 { | |
| dto.FollowUps = followUps | |
| } | |
| return dto | |
| } | |
| // StreamMessage godoc | |
| // | |
| // @Accept json | |
| // @Produce text/event-stream | |
| // @Param id path string true "Session ID" | |
| // @Param request body SendTextRequest true "Request body" | |
| // @Success 200 {string} string "Server-Sent Events stream token per event, ending with JSON {\"finished\":bool,\"stage\":string}" | |
| // @Failure 400 {object} ErrorResponse | |
| // @Failure 500 {object} ErrorResponse | |
| func (h *HTTPHandler) StreamMessage(w http.ResponseWriter, r *http.Request) { | |
| sessionID := r.PathValue("id") | |
| if sessionID == "" { | |
| writeError(w, http.StatusBadRequest, "session_id is required") | |
| return | |
| } | |
| var req SendTextRequest | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| writeError(w, http.StatusBadRequest, "invalid request body") | |
| return | |
| } | |
| if req.Message == "" { | |
| writeError(w, http.StatusBadRequest, "message is required") | |
| return | |
| } | |
| if strings.TrimSpace(req.Message) == "/finish" { | |
| h.handleFinishCommand(w, r, sessionID) | |
| return | |
| } | |
| flusher, ok := w.(http.Flusher) | |
| if !ok { | |
| writeError(w, http.StatusInternalServerError, "streaming not supported") | |
| return | |
| } | |
| w.Header().Set("Content-Type", "text/event-stream") | |
| w.Header().Set("Cache-Control", "no-cache") | |
| w.Header().Set("X-Accel-Buffering", "no") | |
| w.Header().Set("Connection", "keep-alive") | |
| w.WriteHeader(http.StatusOK) | |
| result, err := h.AppService.StreamMessage(r.Context(), sessionID, req.Message, func(chunk string) { | |
| fmt.Fprintf(w, "data: %s\n\n", chunk) | |
| flusher.Flush() | |
| }) | |
| if err != nil { | |
| fmt.Fprintf(w, "data: {\"error\":%q}\n\n", err.Error()) | |
| flusher.Flush() | |
| return | |
| } | |
| meta, _ := json.Marshal(map[string]any{"finished": result.Finished, "stage": result.Stage}) | |
| fmt.Fprintf(w, "data: %s\n\n", string(meta)) | |
| flusher.Flush() | |
| } | |
| func parseMode(s string) domain.InterviewMode { | |
| if s == "audio" { | |
| return domain.ModeAudio | |
| } | |
| return domain.ModeText | |
| } | |
| func writeJSON(w http.ResponseWriter, status int, v any) { | |
| w.Header().Set("Content-Type", "application/json") | |
| w.WriteHeader(status) | |
| _ = json.NewEncoder(w).Encode(v) | |
| } | |
| func writeError(w http.ResponseWriter, status int, msg string) { | |
| writeJSON(w, status, ErrorResponse{Error: msg}) | |
| } | |