572 lines
14 KiB
Go
572 lines
14 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
|
)
|
|
|
|
type ArticleHandler struct {
|
|
svc *app.ArticleService
|
|
selectionOptimize *app.ArticleSelectionOptimizeService
|
|
promptGenerateSvc *app.PromptRuleGenerationService
|
|
imitationSvc *app.ArticleImitationService
|
|
streams *stream.GenerationHub
|
|
app *bootstrap.App
|
|
assets *AssetHandler
|
|
}
|
|
|
|
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
|
knowledgeSvc := app.NewKnowledgeService(
|
|
a.DB,
|
|
a.RetrievalProvider,
|
|
a.VectorStore,
|
|
a.ObjectStorage,
|
|
a.LLM,
|
|
a.Logger,
|
|
a.Config().Retrieval,
|
|
a.Config().LLM,
|
|
0,
|
|
0,
|
|
).WithConfigProvider(a.ConfigStore)
|
|
|
|
return &ArticleHandler{
|
|
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).
|
|
WithCache(a.Cache).
|
|
WithGenerationRuntime(
|
|
a.LLM,
|
|
a.RabbitMQ,
|
|
a.GenerationStreams,
|
|
a.Config().Generation,
|
|
a.Config().LLM.MaxOutputTokens,
|
|
).
|
|
WithConfigProvider(a.ConfigStore),
|
|
selectionOptimize: app.NewArticleSelectionOptimizeService(
|
|
a.DB,
|
|
a.LLM,
|
|
a.Config().LLM.MaxOutputTokens,
|
|
).WithCache(a.Cache).WithConfigProvider(a.ConfigStore),
|
|
promptGenerateSvc: app.NewPromptRuleGenerationService(
|
|
a.DB,
|
|
a.LLM,
|
|
a.RabbitMQ,
|
|
knowledgeSvc,
|
|
a.GenerationStreams,
|
|
a.Config().Generation,
|
|
a.Config().LLM.MaxOutputTokens,
|
|
).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore),
|
|
imitationSvc: app.NewArticleImitationService(
|
|
a.DB,
|
|
a.LLM,
|
|
a.RabbitMQ,
|
|
knowledgeSvc,
|
|
a.GenerationStreams,
|
|
a.Config().Generation,
|
|
a.Config().LLM.MaxOutputTokens,
|
|
).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore),
|
|
streams: a.GenerationStreams,
|
|
app: a,
|
|
assets: NewAssetHandler(a),
|
|
}
|
|
}
|
|
|
|
func (h *ArticleHandler) List(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
|
|
params := app.ArticleListParams{Page: page, PageSize: pageSize}
|
|
if v := c.Query("generate_status"); v != "" {
|
|
params.GenerateStatus = &v
|
|
}
|
|
if v := c.Query("publish_status"); v != "" {
|
|
params.PublishStatus = &v
|
|
}
|
|
if v := normalizeSourceTypeParam(c.Query("source_type")); v != "" {
|
|
params.SourceType = &v
|
|
}
|
|
if v := c.Query("generation_mode"); v != "" {
|
|
params.GenerationMode = &v
|
|
}
|
|
if v := c.Query("template_id"); v != "" {
|
|
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
params.TemplateID = &id
|
|
}
|
|
}
|
|
if v := c.Query("kol_prompt_id"); v != "" {
|
|
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
params.KolPromptID = &id
|
|
}
|
|
}
|
|
if v := c.Query("prompt_rule_id"); v != "" {
|
|
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
params.PromptRuleID = &id
|
|
}
|
|
}
|
|
if v := c.Query("keyword"); v != "" {
|
|
params.Keyword = &v
|
|
}
|
|
if v := c.Query("created_from"); v != "" {
|
|
parsed, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40014, "invalid_created_from", "created_from must be RFC3339"))
|
|
return
|
|
}
|
|
params.CreatedFrom = &parsed
|
|
}
|
|
if v := c.Query("created_to"); v != "" {
|
|
parsed, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40015, "invalid_created_to", "created_to must be RFC3339"))
|
|
return
|
|
}
|
|
params.CreatedTo = &parsed
|
|
}
|
|
|
|
data, err := h.svc.List(c.Request.Context(), params)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func normalizeSourceTypeParam(raw string) string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return ""
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, part := range parts {
|
|
value := strings.TrimSpace(part)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
|
|
return strings.Join(out, ",")
|
|
}
|
|
|
|
func (h *ArticleHandler) GenerateFromRule(c *gin.Context) {
|
|
var req app.GenerateFromRuleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
data, err := h.promptGenerateSvc.GenerateFromRule(c.Request.Context(), req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
|
|
var req app.GenerateImitationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
data, err := h.imitationSvc.Generate(c.Request.Context(), req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) Regenerate(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
|
|
data, err := h.svc.Regenerate(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) Create(c *gin.Context) {
|
|
var req app.CreateArticleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
// Allow empty body
|
|
req = app.CreateArticleRequest{}
|
|
}
|
|
|
|
data, err := h.svc.Create(c.Request.Context(), req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) Detail(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
data, err := h.svc.Detail(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) Versions(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
data, err := h.svc.Versions(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) Update(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
|
|
var req app.UpdateArticleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error()))
|
|
return
|
|
}
|
|
|
|
data, err := h.svc.Update(c.Request.Context(), id, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *ArticleHandler) UploadImage(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file is required"))
|
|
return
|
|
}
|
|
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be opened"))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be read"))
|
|
return
|
|
}
|
|
|
|
data, err := h.svc.UploadImage(c.Request.Context(), id, fileHeader.Filename, content)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
data.URL = h.assets.BuildArticleImageURL(data.ObjectKey)
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
|
}
|
|
|
|
type articleSelectionOptimizeStreamEvent struct {
|
|
ArticleID int64 `json:"article_id"`
|
|
Status string `json:"status,omitempty"`
|
|
Delta string `json:"delta,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Done bool `json:"done"`
|
|
}
|
|
|
|
func (h *ArticleHandler) OptimizeSelectionStream(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
|
|
var req app.ArticleSelectionOptimizeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := h.selectionOptimize.ValidateRequest(c.Request.Context(), id, req); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming"))
|
|
return
|
|
}
|
|
|
|
if err := writeSSE(c, "start", articleSelectionOptimizeStreamEvent{
|
|
ArticleID: id,
|
|
Status: "generating",
|
|
Done: false,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
|
|
streamCtx, cancel := context.WithCancel(c.Request.Context())
|
|
defer cancel()
|
|
|
|
var streamed strings.Builder
|
|
var streamWriteErr error
|
|
|
|
result, err := h.selectionOptimize.OptimizeSelection(streamCtx, id, req, func(delta string) {
|
|
if delta == "" || streamWriteErr != nil {
|
|
return
|
|
}
|
|
|
|
streamed.WriteString(delta)
|
|
if writeErr := writeSSE(c, "delta", articleSelectionOptimizeStreamEvent{
|
|
ArticleID: id,
|
|
Status: "generating",
|
|
Delta: delta,
|
|
Content: streamed.String(),
|
|
Done: false,
|
|
}); writeErr != nil {
|
|
streamWriteErr = writeErr
|
|
cancel()
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
})
|
|
if streamWriteErr != nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
if errors.Is(streamCtx.Err(), context.Canceled) {
|
|
return
|
|
}
|
|
|
|
appErr := response.Normalize(err)
|
|
_ = writeSSE(c, "error", articleSelectionOptimizeStreamEvent{
|
|
ArticleID: id,
|
|
Status: "failed",
|
|
Content: strings.TrimSpace(streamed.String()),
|
|
Error: resolveOptimizeStreamErrorMessage(appErr),
|
|
Done: true,
|
|
})
|
|
flusher.Flush()
|
|
return
|
|
}
|
|
|
|
if err := writeSSE(c, "completed", articleSelectionOptimizeStreamEvent{
|
|
ArticleID: id,
|
|
Status: "completed",
|
|
Content: result.Content,
|
|
Model: result.Model,
|
|
Done: true,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
|
|
func (h *ArticleHandler) Stream(c *gin.Context) {
|
|
cfg := h.app.Config()
|
|
if cfg == nil || !cfg.Generation.StreamEnabled {
|
|
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
|
|
detail, err := h.svc.Detail(c.Request.Context(), id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming"))
|
|
return
|
|
}
|
|
|
|
fallback := buildGenerationSnapshot(detail)
|
|
if snapshot, ok := h.streams.Snapshot(id); !ok || (fallback.Done && snapshot.Status != fallback.Status) {
|
|
h.streams.Seed(fallback)
|
|
}
|
|
|
|
events, snapshot, cancel := h.streams.Subscribe(id)
|
|
defer cancel()
|
|
|
|
if snapshot != nil {
|
|
if err := writeSSE(c, snapshot.Type, snapshot); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
if snapshot.Done {
|
|
return
|
|
}
|
|
} else {
|
|
if err := writeSSE(c, fallback.Type, fallback); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
if fallback.Done {
|
|
return
|
|
}
|
|
}
|
|
|
|
heartbeat := time.NewTicker(15 * time.Second)
|
|
defer heartbeat.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.Request.Context().Done():
|
|
return
|
|
case <-heartbeat.C:
|
|
if err := writeSSE(c, "ping", gin.H{"ts": time.Now().UTC()}); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
case event, ok := <-events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := writeSSE(c, event.Type, event); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
if event.Done {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ArticleHandler) Delete(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
|
return
|
|
}
|
|
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func writeSSE(c *gin.Context, event string, payload interface{}) error {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := c.Writer.Write([]byte("event: " + event + "\n")); err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.Writer.Write([]byte("data: " + string(data) + "\n\n")); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildGenerationSnapshot(detail *app.ArticleDetailResponse) stream.GenerationEvent {
|
|
event := stream.GenerationEvent{
|
|
Type: "snapshot",
|
|
ArticleID: detail.ID,
|
|
Status: detail.GenerateStatus,
|
|
Done: detail.GenerateStatus == "completed" || detail.GenerateStatus == "failed",
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
if detail.Title != nil {
|
|
event.Title = *detail.Title
|
|
}
|
|
if detail.MarkdownContent != nil {
|
|
event.Content = *detail.MarkdownContent
|
|
}
|
|
if detail.GenerateStatus == "completed" {
|
|
event.Type = "completed"
|
|
}
|
|
if detail.GenerateStatus == "failed" {
|
|
event.Type = "error"
|
|
if detail.GenerationErrorMessage != nil {
|
|
event.Error = *detail.GenerationErrorMessage
|
|
}
|
|
}
|
|
return event
|
|
}
|
|
|
|
func resolveOptimizeStreamErrorMessage(appErr *response.AppError) string {
|
|
if appErr == nil {
|
|
return "optimize selection failed"
|
|
}
|
|
if detail := strings.TrimSpace(appErr.Detail); detail != "" {
|
|
return detail
|
|
}
|
|
if message := strings.TrimSpace(appErr.Message); message != "" {
|
|
return message
|
|
}
|
|
return "optimize selection failed"
|
|
}
|