feat: add knowledge management functionality with CRUD operations and database schema

- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations.
- Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta.
- Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
This commit is contained in:
2026-04-05 17:14:13 +08:00
parent 134dd063c3
commit 446f865cdf
62 changed files with 9503 additions and 2664 deletions
@@ -0,0 +1,315 @@
package app
import (
"archive/zip"
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
pdf "github.com/ledongthuc/pdf"
"github.com/xuri/excelize/v2"
"golang.org/x/net/html"
)
const (
maxKnowledgeFetchBytes = 5 << 20
)
var whitespacePattern = regexp.MustCompile(`[ \t]+`)
func extractKnowledgeTextFromURL(ctx context.Context, rawURL string) (string, error) {
data, contentType, err := fetchKnowledgeURLSnapshot(ctx, rawURL)
if err != nil {
return "", err
}
return extractKnowledgeTextFromSnapshot(resolveSnapshotFileName(rawURL, contentType), data)
}
func fetchKnowledgeURLSnapshot(ctx context.Context, rawURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(rawURL), nil)
if err != nil {
return nil, "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "geo-knowledge-fetcher/1.0")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("fetch url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return nil, "", fmt.Errorf("fetch url failed: status=%d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxKnowledgeFetchBytes))
if err != nil {
return nil, "", fmt.Errorf("read url body: %w", err)
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
return data, contentType, nil
}
func resolveSnapshotFileName(rawURL string, contentType string) string {
if strings.Contains(strings.ToLower(contentType), "text/plain") {
return rawURL + ".txt"
}
return rawURL + ".html"
}
func extractKnowledgeTextFromSnapshot(fileName string, content []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(fileName))
if ext != ".html" && ext != ".htm" {
return extractKnowledgeTextFromFile(fileName, content)
}
doc, err := html.Parse(bytes.NewReader(content))
if err != nil {
return "", fmt.Errorf("parse html: %w", err)
}
var parts []string
var walk func(*html.Node, bool)
walk = func(node *html.Node, skip bool) {
if node == nil {
return
}
nextSkip := skip
if node.Type == html.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "noscript", "svg":
nextSkip = true
}
}
if node.Type == html.TextNode && !nextSkip {
text := strings.TrimSpace(node.Data)
if text != "" {
parts = append(parts, text)
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, nextSkip)
}
}
walk(doc, false)
return normalizeKnowledgeText(strings.Join(parts, "\n")), nil
}
func extractKnowledgeTextFromFile(fileName string, content []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".txt", ".md", ".markdown", ".json", ".csv", ".html", ".htm":
if !utf8.Valid(content) {
return "", fmt.Errorf("file %s is not valid UTF-8 text", fileName)
}
return normalizeKnowledgeText(string(content)), nil
case ".docx":
return extractDOCXText(content)
case ".xlsx":
return extractXLSXText(content)
case ".pdf":
return extractPDFText(content)
case ".doc", ".xls":
return "", fmt.Errorf("file type %s is not supported yet", ext)
default:
if utf8.Valid(content) {
return normalizeKnowledgeText(string(content)), nil
}
return "", fmt.Errorf("unsupported file type %s", ext)
}
}
func extractDOCXText(content []byte) (string, error) {
reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("open docx: %w", err)
}
var parts []string
for _, file := range reader.File {
if file.Name != "word/document.xml" {
continue
}
rc, err := file.Open()
if err != nil {
return "", fmt.Errorf("open docx entry: %w", err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return "", fmt.Errorf("read docx entry: %w", err)
}
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, tokenErr := decoder.Token()
if tokenErr == io.EOF {
break
}
if tokenErr != nil {
return "", fmt.Errorf("decode docx xml: %w", tokenErr)
}
switch typed := token.(type) {
case xml.CharData:
text := strings.TrimSpace(string(typed))
if text != "" {
parts = append(parts, text)
}
}
}
}
if len(parts) == 0 {
return "", fmt.Errorf("docx contains no readable text")
}
return normalizeKnowledgeText(strings.Join(parts, "\n")), nil
}
func extractXLSXText(content []byte) (string, error) {
book, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
return "", fmt.Errorf("open xlsx: %w", err)
}
defer func() { _ = book.Close() }()
lines := make([]string, 0)
for _, sheet := range book.GetSheetList() {
rows, err := book.GetRows(sheet)
if err != nil {
return "", fmt.Errorf("read xlsx rows: %w", err)
}
if len(rows) == 0 {
continue
}
lines = append(lines, sheet)
for _, row := range rows {
cells := make([]string, 0, len(row))
for _, cell := range row {
cell = strings.TrimSpace(cell)
if cell != "" {
cells = append(cells, cell)
}
}
if len(cells) > 0 {
lines = append(lines, strings.Join(cells, " | "))
}
}
}
if len(lines) == 0 {
return "", fmt.Errorf("xlsx contains no readable text")
}
return normalizeKnowledgeText(strings.Join(lines, "\n")), nil
}
func extractPDFText(content []byte) (string, error) {
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("open pdf: %w", err)
}
var builder strings.Builder
totalPage := reader.NumPage()
for pageIndex := 1; pageIndex <= totalPage; pageIndex += 1 {
page := reader.Page(pageIndex)
if page.V.IsNull() {
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
return "", fmt.Errorf("extract pdf text: %w", err)
}
if text != "" {
builder.WriteString(text)
builder.WriteString("\n")
}
}
result := normalizeKnowledgeText(builder.String())
if result == "" {
return "", fmt.Errorf("pdf contains no readable text")
}
return result, nil
}
func normalizeKnowledgeText(input string) string {
input = strings.ReplaceAll(input, "\u00a0", " ")
lines := strings.Split(input, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = whitespacePattern.ReplaceAllString(strings.TrimSpace(line), " ")
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
func splitKnowledgeTextIntoChunks(text string, chunkSize, chunkOverlap int) []string {
text = normalizeKnowledgeText(text)
if text == "" {
return nil
}
runes := []rune(text)
if chunkSize <= 0 {
chunkSize = 900
}
if chunkOverlap < 0 {
chunkOverlap = 0
}
if chunkOverlap >= chunkSize {
chunkOverlap = chunkSize / 4
}
chunks := make([]string, 0)
for start := 0; start < len(runes); {
end := start + chunkSize
if end > len(runes) {
end = len(runes)
}
if end < len(runes) {
for offset := end; offset > start+chunkSize/2; offset -= 1 {
if runes[offset-1] == '\n' || runes[offset-1] == '。' || runes[offset-1] == '.' {
end = offset
break
}
}
}
chunk := strings.TrimSpace(string(runes[start:end]))
if chunk != "" {
chunks = append(chunks, chunk)
}
if end >= len(runes) {
break
}
start = end - chunkOverlap
if start < 0 {
start = 0
}
}
return chunks
}
func estimateKnowledgeTokenCount(text string) int {
if text == "" {
return 0
}
return utf8.RuneCountInString(text)
}
File diff suppressed because it is too large Load Diff
@@ -15,12 +15,14 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/llm"
"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/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type PromptRuleGenerationService struct {
pool *pgxpool.Pool
llm llm.Client
knowledge *KnowledgeService
streams *stream.GenerationHub
streamEnabled bool
articleTimeout time.Duration
@@ -53,13 +55,15 @@ type GenerateFromRuleResponse struct {
}
type promptRuleGenerationRecord struct {
ID int64
Name string
PromptContent string
Scene *string
DefaultTone *string
DefaultWordCount *int
Status string
ID int64
Name string
PromptContent string
Scene *string
DefaultTone *string
DefaultWordCount *int
KnowledgeGroupIDs []int64
KnowledgeGroups []PromptRuleKnowledgeGroup
Status string
}
const promptRuleGeneratingTitlePlaceholder = "标题生成中"
@@ -67,6 +71,7 @@ const promptRuleGeneratingTitlePlaceholder = "标题生成中"
func NewPromptRuleGenerationService(
pool *pgxpool.Pool,
llmClient llm.Client,
knowledge *KnowledgeService,
streams *stream.GenerationHub,
cfg config.GenerationConfig,
llmMaxOutputTokens int64,
@@ -89,6 +94,7 @@ func NewPromptRuleGenerationService(
svc := &PromptRuleGenerationService{
pool: pool,
llm: llmClient,
knowledge: knowledge,
streams: streams,
streamEnabled: cfg.StreamEnabled,
articleTimeout: articleTimeout,
@@ -153,6 +159,26 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
if req.BrandID != nil {
inputParams["brand_id"] = *req.BrandID
}
if len(rule.KnowledgeGroupIDs) > 0 {
inputParams["knowledge_group_ids"] = rule.KnowledgeGroupIDs
}
knowledgePrompt := ""
if len(rule.KnowledgeGroupIDs) > 0 {
if s.knowledge == nil {
return nil, response.ErrServiceUnavailable(50352, "knowledge_unavailable", "knowledge service is not configured")
}
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
ctx,
actor.TenantID,
rule.KnowledgeGroupIDs,
buildPromptRuleKnowledgeQuery(rule, inputParams),
)
if resolveErr != nil {
return nil, resolveErr
}
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
}
inputJSON, _ := json.Marshal(inputParams)
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs)
@@ -233,7 +259,7 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
ArticleID: articleID,
TaskID: taskID,
ReservationID: reservationID,
Prompt: buildPromptRuleGenerationPrompt(rule, inputParams),
Prompt: buildPromptRuleGenerationPrompt(rule, inputParams, knowledgePrompt),
InputParams: inputParams,
InitialTitle: promptRuleGeneratingTitlePlaceholder,
WebSearch: llm.WebSearchOptions{
@@ -271,6 +297,28 @@ func (s *PromptRuleGenerationService) loadPromptRule(ctx context.Context, tenant
if err != nil {
return nil, response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
}
knowledgeRows, err := s.pool.Query(ctx, `
SELECT kg.id, kg.name
FROM prompt_rule_knowledge_groups prkg
JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id
WHERE prkg.prompt_rule_id = $1
AND kg.tenant_id = $2
AND kg.deleted_at IS NULL
ORDER BY kg.sort_order, kg.created_at, kg.id
`, promptRuleID, tenantID)
if err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups")
}
defer knowledgeRows.Close()
for knowledgeRows.Next() {
var group PromptRuleKnowledgeGroup
if err := knowledgeRows.Scan(&group.ID, &group.Name); err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
}
record.KnowledgeGroups = append(record.KnowledgeGroups, group)
record.KnowledgeGroupIDs = append(record.KnowledgeGroupIDs, group.ID)
}
return record, nil
}
@@ -447,34 +495,60 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
}
}
func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params map[string]interface{}, knowledgePrompt string) string {
sections := []string{strings.TrimSpace(rule.PromptContent)}
var supplements []string
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
supplements = append(supplements, "任务名称:"+taskName)
supplements = append(supplements, prompts.PromptRuleTaskNameSupplement(taskName))
}
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
supplements = append(supplements, "适用场景:"+strings.TrimSpace(*rule.Scene))
supplements = append(supplements, prompts.PromptRuleSceneSupplement(strings.TrimSpace(*rule.Scene)))
}
if rule.DefaultTone != nil && strings.TrimSpace(*rule.DefaultTone) != "" {
supplements = append(supplements, "默认语气:"+strings.TrimSpace(*rule.DefaultTone))
supplements = append(supplements, prompts.PromptRuleToneSupplement(strings.TrimSpace(*rule.DefaultTone)))
}
if rule.DefaultWordCount != nil && *rule.DefaultWordCount > 0 {
supplements = append(supplements, "建议字数:"+strconv.Itoa(*rule.DefaultWordCount)+" 字左右")
supplements = append(supplements, prompts.PromptRuleWordCountSupplement(*rule.DefaultWordCount))
}
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
supplements = append(supplements, "目标发布平台:"+strings.ReplaceAll(target, ",", "、"))
supplements = append(supplements, prompts.PromptRuleTargetPlatformSupplement(target))
}
if len(supplements) > 0 {
sections = append(sections, "补充要求:\n- "+strings.Join(supplements, "\n- "))
sections = append(sections, prompts.PromptRuleSupplementSection(supplements))
}
sections = append(sections, "输出要求:\n1. 直接输出完整可发布的中文 Markdown 文章。\n2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。\n3. 不要输出你的思考过程、解释或致歉。")
if strings.TrimSpace(knowledgePrompt) != "" {
sections = append(sections, knowledgePrompt)
}
sections = append(sections, prompts.PromptRuleOutputRequirementsSection())
return strings.Join(sections, "\n\n")
}
func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
parts := make([]string, 0, 6)
if rule != nil {
if text := strings.TrimSpace(rule.Name); text != "" {
parts = append(parts, text)
}
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
parts = append(parts, strings.TrimSpace(*rule.Scene))
}
if text := strings.TrimSpace(rule.PromptContent); text != "" {
parts = append(parts, text)
}
}
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
parts = append(parts, taskName)
}
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
parts = append(parts, target)
}
return strings.Join(parts, "\n")
}
func extractGenerateCount(extra map[string]interface{}) int {
value := extractInt(extra, "generate_count")
if value <= 0 {
+219 -28
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
@@ -39,35 +40,43 @@ type PromptRuleGroupResponse struct {
// --- Rule types ---
type PromptRuleRequest struct {
GroupID *int64 `json:"group_id"`
Name string `json:"name" binding:"required"`
PromptContent string `json:"prompt_content" binding:"required"`
Scene *string `json:"scene"`
DefaultTone *string `json:"default_tone"`
DefaultWordCount *int `json:"default_word_count"`
GroupID *int64 `json:"group_id"`
Name string `json:"name" binding:"required"`
PromptContent string `json:"prompt_content" binding:"required"`
Scene *string `json:"scene"`
DefaultTone *string `json:"default_tone"`
DefaultWordCount *int `json:"default_word_count"`
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
}
type PromptRuleKnowledgeGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type PromptRuleResponse struct {
ID int64 `json:"id"`
GroupID *int64 `json:"group_id"`
Name string `json:"name"`
PromptContent string `json:"prompt_content"`
Scene *string `json:"scene"`
DefaultTone *string `json:"default_tone"`
DefaultWordCount *int `json:"default_word_count"`
Status string `json:"status"`
ArticleCount int `json:"article_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
GroupID *int64 `json:"group_id"`
Name string `json:"name"`
PromptContent string `json:"prompt_content"`
Scene *string `json:"scene"`
DefaultTone *string `json:"default_tone"`
DefaultWordCount *int `json:"default_word_count"`
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
KnowledgeGroups []PromptRuleKnowledgeGroup `json:"knowledge_groups"`
Status string `json:"status"`
ArticleCount int `json:"article_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type PromptRuleListParams struct {
GroupID *int64 `json:"group_id"`
Status *string `json:"status"`
Keyword *string `json:"keyword"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Ungrouped bool `json:"ungrouped"`
GroupID *int64 `json:"group_id"`
Status *string `json:"status"`
Keyword *string `json:"keyword"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Ungrouped bool `json:"ungrouped"`
}
type PromptRuleListResponse struct {
@@ -297,12 +306,23 @@ func (s *PromptRuleService) List(ctx context.Context, params PromptRuleListParam
}
r.CreatedAt = fmt.Sprintf("%v", ca)
r.UpdatedAt = fmt.Sprintf("%v", ua)
r.KnowledgeGroupIDs = []int64{}
r.KnowledgeGroups = []PromptRuleKnowledgeGroup{}
items = append(items, r)
}
if items == nil {
items = []PromptRuleResponse{}
}
groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, collectPromptRuleIDs(items))
if err != nil {
return nil, err
}
for index := range items {
items[index].KnowledgeGroups = groupMap[items[index].ID]
items[index].KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(items[index].KnowledgeGroups)
}
return &PromptRuleListResponse{Items: items, Total: total}, nil
}
@@ -327,14 +347,32 @@ func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleRe
r.CreatedAt = fmt.Sprintf("%v", ca)
r.UpdatedAt = fmt.Sprintf("%v", ua)
r.ArticleCount = articleCount
groupMap, groupErr := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, []int64{id})
if groupErr != nil {
return nil, groupErr
}
r.KnowledgeGroups = groupMap[id]
r.KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(r.KnowledgeGroups)
return &r, nil
}
func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) {
actor := auth.MustActor(ctx)
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule")
}
defer func() { _ = tx.Rollback(ctx) }()
var id int64
var ca interface{}
err := s.pool.QueryRow(ctx, `
err = tx.QueryRow(ctx, `
INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at
`, actor.TenantID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca)
@@ -342,6 +380,13 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (
return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule")
}
if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to commit prompt rule")
}
afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name})
result := "success"
resourceType := "prompt_rule"
@@ -359,15 +404,36 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (
})
return &PromptRuleResponse{
ID: id, GroupID: req.GroupID, Name: req.Name, PromptContent: req.PromptContent,
Scene: req.Scene, DefaultTone: req.DefaultTone, DefaultWordCount: req.DefaultWordCount,
Status: "enabled", ArticleCount: 0, CreatedAt: fmt.Sprintf("%v", ca),
ID: id,
GroupID: req.GroupID,
Name: req.Name,
PromptContent: req.PromptContent,
Scene: req.Scene,
DefaultTone: req.DefaultTone,
DefaultWordCount: req.DefaultWordCount,
KnowledgeGroupIDs: promptRuleKnowledgeGroupIDs(knowledgeGroups),
KnowledgeGroups: knowledgeGroups,
Status: "enabled",
ArticleCount: 0,
CreatedAt: fmt.Sprintf("%v", ca),
UpdatedAt: fmt.Sprintf("%v", ca),
}, nil
}
func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error {
actor := auth.MustActor(ctx)
tag, err := s.pool.Exec(ctx, `
knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs)
if err != nil {
return err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule")
}
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, `
UPDATE prompt_rules SET name = $1, group_id = $2, prompt_content = $3,
scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW()
WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL
@@ -375,6 +441,12 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found")
}
if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50010, "update_failed", "failed to commit prompt rule")
}
return nil
}
@@ -443,3 +515,122 @@ func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple,
}
return items, nil
}
func (s *PromptRuleService) validatePromptRuleKnowledgeGroups(
ctx context.Context,
tenantID int64,
groupIDs []int64,
) ([]PromptRuleKnowledgeGroup, error) {
groupIDs = normalizeInt64IDs(groupIDs)
if len(groupIDs) == 0 {
return []PromptRuleKnowledgeGroup{}, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, name
FROM knowledge_groups
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
ORDER BY sort_order, created_at, id
`, tenantID, groupIDs)
if err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups")
}
defer rows.Close()
groups := make([]PromptRuleKnowledgeGroup, 0, len(groupIDs))
found := make(map[int64]struct{}, len(groupIDs))
for rows.Next() {
var item PromptRuleKnowledgeGroup
if err := rows.Scan(&item.ID, &item.Name); err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
}
groups = append(groups, item)
found[item.ID] = struct{}{}
}
if len(found) != len(groupIDs) {
return nil, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found")
}
return groups, nil
}
func (s *PromptRuleService) loadPromptRuleKnowledgeGroupMap(
ctx context.Context,
tenantID int64,
ruleIDs []int64,
) (map[int64][]PromptRuleKnowledgeGroup, error) {
result := make(map[int64][]PromptRuleKnowledgeGroup, len(ruleIDs))
ruleIDs = normalizeInt64IDs(ruleIDs)
if len(ruleIDs) == 0 {
return result, nil
}
rows, err := s.pool.Query(ctx, `
SELECT prkg.prompt_rule_id, kg.id, kg.name
FROM prompt_rule_knowledge_groups prkg
JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id
WHERE prkg.prompt_rule_id = ANY($1::bigint[])
AND kg.tenant_id = $2
AND kg.deleted_at IS NULL
ORDER BY kg.sort_order, kg.created_at, kg.id
`, ruleIDs, tenantID)
if err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query prompt rule knowledge groups")
}
defer rows.Close()
for rows.Next() {
var (
ruleID int64
group PromptRuleKnowledgeGroup
)
if err := rows.Scan(&ruleID, &group.ID, &group.Name); err != nil {
return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error())
}
result[ruleID] = append(result[ruleID], group)
}
return result, nil
}
func (s *PromptRuleService) replacePromptRuleKnowledgeGroups(
ctx context.Context,
tx pgx.Tx,
promptRuleID int64,
groupIDs []int64,
) error {
if _, err := tx.Exec(ctx, `
DELETE FROM prompt_rule_knowledge_groups
WHERE prompt_rule_id = $1
`, promptRuleID); err != nil {
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups")
}
for _, groupID := range normalizeInt64IDs(groupIDs) {
if _, err := tx.Exec(ctx, `
INSERT INTO prompt_rule_knowledge_groups (prompt_rule_id, knowledge_group_id)
VALUES ($1, $2)
`, promptRuleID, groupID); err != nil {
return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups")
}
}
return nil
}
func collectPromptRuleIDs(items []PromptRuleResponse) []int64 {
ids := make([]int64, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
return normalizeInt64IDs(ids)
}
func promptRuleKnowledgeGroupIDs(groups []PromptRuleKnowledgeGroup) []int64 {
ids := make([]int64, 0, len(groups))
for _, group := range groups {
ids = append(ids, group.ID)
}
return normalizeInt64IDs(ids)
}
+12 -134
View File
@@ -13,6 +13,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/llm"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
@@ -474,7 +475,7 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
ResponseFormat: &llm.ResponseFormat{
Type: llm.ResponseFormatTypeJSONObject,
Name: "article_outline",
Description: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
Description: prompts.OutlineResponseFormatDescription(),
},
}, nil)
if err != nil {
@@ -666,56 +667,12 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
basePrompt := strings.TrimSpace(renderPromptTemplate(analyzePromptTemplate, contextPayload))
if basePrompt != "" {
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
outputExample := `{
"brand_summary": "一句简洁的品牌/主题描述",
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
"competitors": [
{
"name": "竞品名",
"website": "https://example.com",
"description": "一句简介"
}
]
}`
return strings.TrimSpace(fmt.Sprintf("%s\n\n模板上下文:\n%s\n\nJSON 输出示例:\n%s", basePrompt, string(contextJSON), outputExample))
return prompts.TemplateContextWithJSONExample(basePrompt, string(contextJSON), prompts.AnalyzeOutputExample())
}
}
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
outputExample := `{
"brand_summary": "一句简洁的品牌/主题描述",
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
"competitors": [
{
"name": "竞品名",
"website": "https://example.com",
"description": "一句简介"
}
]
}`
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
1. 分析品牌/主题上下文。
2. 推荐最相关的 GEO 文章关键词。
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 关键词应为简洁的搜索短语。
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
- brand_summary 限 1-2 句。
- 最多返回 6 个竞品和 5 个关键词。
模板上下文:
%s
JSON 输出示例:
%s
`, string(contextJSON), outputExample))
return prompts.AnalyzeFallbackPrompt(string(contextJSON))
}
func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, titlePromptTemplate *string) string {
@@ -725,115 +682,36 @@ func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, ti
if basePrompt != "" {
sections := []string{basePrompt}
if contextBlock := buildPromptContext(contextPayload); contextBlock != "" {
sections = append(sections, "当前上下文:\n"+contextBlock)
sections = append(sections, prompts.PromptContextSection(contextBlock))
}
sections = append(sections, strings.Join([]string{
"输出要求:",
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
"- 返回恰好 5 个字符串的 JSON 数组。",
"- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。",
}, "\n"))
sections = append(sections, prompts.TitleCustomOutputRequirementsSection())
return strings.Join(sections, "\n\n")
}
}
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
outputExample := `[
"标题 1",
"标题 2",
"标题 3",
"标题 4",
"标题 5"
]`
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成 5 个优质文章标题候选。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 5 个字符串的 JSON 数组,不要返回对象。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 标题应实用、具体,并契合当前模板的内容方向。
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
- 避免泛泛的广告文案和空洞口号。
模板上下文:
%s
JSON 输出示例:
%s
`, string(contextJSON), outputExample))
return prompts.TitleFallbackPrompt(string(contextJSON))
}
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
contextPayload := outlinePromptParams(templateKey, templateName, req)
outputExample := `{
"outline": [
{
"outline": "网站列表",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
]
},
{
"outline": "文章关键要点",
"children": [
{ "outline": "要点 1" },
{ "outline": "要点 2" }
]
}
]
}`
outputExample := prompts.OutlineOutputExample()
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
if basePrompt != "" {
sections := []string{basePrompt}
if contextBlock := buildPromptContext(contextPayload); contextBlock != "" {
sections = append(sections, "当前上下文:\n"+contextBlock)
sections = append(sections, prompts.PromptContextSection(contextBlock))
}
sections = append(sections, strings.Join([]string{
"输出要求:",
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
}, "\n"))
sections = append(sections, "JSON 输出示例:\n"+outputExample)
sections = append(sections, prompts.OutlineCustomOutputRequirementsSection())
sections = append(sections, prompts.JSONOutputExampleSection(outputExample))
return strings.Join(sections, "\n\n")
}
}
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
任务:
为当前内容摘要生成一份实用的文章大纲。
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
模板上下文:
%s
JSON 输出示例:
%s
`, string(contextJSON), outputExample))
return prompts.OutlineFallbackPrompt(string(contextJSON))
}
func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) map[string]interface{} {
+50 -95
View File
@@ -7,14 +7,20 @@ import (
"strings"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
)
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
func buildGenerationPrompt(templateKey, templateName string, promptTemplate *string, params map[string]interface{}) string {
func buildGenerationPrompt(
templateKey, templateName string,
promptTemplate *string,
params map[string]interface{},
knowledgePrompt string,
) string {
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
if basePrompt == "" {
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
basePrompt = prompts.DefaultGenerationBasePrompt(templateName)
}
var sections []string
@@ -22,35 +28,52 @@ func buildGenerationPrompt(templateKey, templateName string, promptTemplate *str
contextBlock := buildPromptContext(params)
if contextBlock != "" {
sections = append(sections, "当前上下文:\n"+contextBlock)
sections = append(sections, prompts.PromptContextSection(contextBlock))
}
sections = append(sections, strings.Join([]string{
"写作总要求:",
"- 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。",
"- 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。",
"- 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。",
"- 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。",
"- 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。",
"- 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。",
"- 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。",
"- 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。",
"- 保持客观、克制、非广告化,不使用夸张宣传语,如“顶级”“颠覆性”“完美”等。",
"- 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。",
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
}, "\n"))
if strings.TrimSpace(knowledgePrompt) != "" {
sections = append(sections, knowledgePrompt)
}
sections = append(sections, prompts.GenerationWritingRequirementsSection())
if templateRules := buildTemplateSpecificWritingRules(templateKey, params); templateRules != "" {
sections = append(sections, "模板专项要求:\n"+templateRules)
sections = append(sections, prompts.GenerationTemplateSpecificRulesHeading()+"\n"+templateRules)
}
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
sections = append(sections, prompts.GenerationLengthGuidanceHeading()+"\n"+lengthGuidance)
}
return strings.Join(sections, "\n\n")
}
func buildGenerationKnowledgeQuery(params map[string]interface{}) string {
if len(params) == 0 {
return ""
}
parts := make([]string, 0, 8)
appendValue := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
parts = append(parts, value)
}
appendValue(stringValue(params["title"]))
appendValue(stringValue(params["topic"]))
appendValue(stringValue(params["product_name"]))
appendValue(stringValue(params["subject"]))
appendValue(stringValue(params["brand_name"]))
appendValue(stringValue(params["primary_keyword"]))
appendValue(stringValue(params["key_points"]))
appendValue(stringValue(params["review_intro_hook"]))
return strings.Join(parts, "\n")
}
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
switch strings.TrimSpace(templateKey) {
case "top_x_article":
@@ -58,14 +81,7 @@ func buildTemplateSpecificWritingRules(templateKey string, params map[string]int
if brandName == "" {
return ""
}
return strings.Join([]string{
fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName),
fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName),
"- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。",
fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName),
fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName),
"- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。",
}, "\n")
return prompts.TopXBrandPriorityRules(brandName)
default:
return ""
}
@@ -91,11 +107,7 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string {
if maxWords > 2100 {
maxWords = 2100
}
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
"- 一段能说清的内容不要拆成多段重复表达。",
}, "\n")
return prompts.EnglishLengthGuidance(minWords, maxWords)
}
minChars := 1100
@@ -111,11 +123,7 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string {
maxChars = 2400
}
return strings.Join([]string{
fmt.Sprintf("- 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。", minChars, maxChars),
"- 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。",
"- 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。",
}, "\n")
return prompts.ChineseLengthGuidance(minChars, maxChars)
}
func estimateTopLevelSectionCount(params map[string]interface{}) int {
@@ -199,6 +207,10 @@ func buildPromptContext(params map[string]interface{}) string {
if value == nil {
return
}
switch key {
case "knowledge_group_ids", "knowledge_groups", "knowledge_context":
return
}
if key == "outline_sections" && hasStructuredOutline(params["article_outline"]) {
return
}
@@ -225,64 +237,7 @@ func buildPromptContext(params map[string]interface{}) string {
}
func promptContextLabel(key string) string {
switch key {
case "locale":
return "语言"
case "title":
return "标题"
case "topic":
return "主题"
case "product_name":
return "产品名"
case "subject":
return "研究主题"
case "brand_name":
return "品牌名"
case "brand":
return "品牌"
case "official_website", "website":
return "官网"
case "primary_keyword":
return "核心关键词"
case "keywords", "existing_keywords":
return "关键词"
case "competitors", "existing_competitors":
return "竞品"
case "competitor_names":
return "竞品名称"
case "competitor_count":
return "竞品数量"
case "brand_summary":
return "品牌摘要"
case "category":
return "品类"
case "count":
return "数量"
case "top_count":
return "推荐数量"
case "keyword_count":
return "关键词数量"
case "depth":
return "深度"
case "article_outline":
return "文章大纲"
case "outline_sections":
return "已选段落"
case "key_points":
return "关键要点"
case "review_intro_hook":
return "评测引言钩子"
case "template_key":
return "模板标识"
case "template_name":
return "模板名称"
case "current_year":
return "当前年份"
case "input_params":
return "输入参数"
default:
return key
}
return prompts.ContextLabel(key)
}
func formatPromptContextValue(key string, value interface{}) string {
@@ -49,7 +49,7 @@ func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) {
"topic": "合肥全屋定制",
"brand_name": "安徽海翔家居用品销售有限公司",
"locale": "zh-CN",
})
}, "")
for _, expected := range []string{
"模板专项要求:",
@@ -68,7 +68,7 @@ func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testi
prompt := buildGenerationPrompt("product_review", "产品评测", nil, map[string]interface{}{
"product_name": "测试产品",
"brand_name": "安徽海翔家居用品销售有限公司",
})
}, "")
if strings.Contains(prompt, "排在第 1 位") {
t.Fatalf("buildGenerationPrompt() = %q, should not include top-x ranking rules", prompt)
+52 -2
View File
@@ -22,6 +22,7 @@ type TemplateService struct {
pool *pgxpool.Pool
templates repository.TemplateRepository
llm llm.Client
knowledge *KnowledgeService
streams *stream.GenerationHub
streamEnabled bool
articleTimeout time.Duration
@@ -48,6 +49,7 @@ func NewTemplateService(
pool *pgxpool.Pool,
templates repository.TemplateRepository,
llmClient llm.Client,
knowledge *KnowledgeService,
streams *stream.GenerationHub,
cfg config.GenerationConfig,
llmMaxOutputTokens int64,
@@ -71,6 +73,7 @@ func NewTemplateService(
pool: pool,
templates: templates,
llm: llmClient,
knowledge: knowledge,
streams: streams,
streamEnabled: cfg.StreamEnabled,
articleTimeout: articleTimeout,
@@ -140,7 +143,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
}
detail := &TemplateDetail{
detail := &TemplateDetail{
TemplateListItem: TemplateListItem{
ID: record.ID,
Scope: record.Scope,
@@ -454,7 +457,26 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
StartedAt: &now,
})
prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params)
knowledgePrompt := ""
if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.Params["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
if s.knowledge == nil {
s.failGeneration(ctx, job, title, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
return
}
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
ctx,
job.TenantID,
knowledgeGroupIDs,
buildGenerationKnowledgeQuery(job.Params),
)
if resolveErr != nil {
s.failGeneration(ctx, job, title, "knowledge_resolve", resolveErr)
return
}
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
}
prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params, knowledgePrompt)
generateReq := llm.GenerateRequest{
Prompt: prompt,
Timeout: s.articleTimeout,
@@ -604,6 +626,8 @@ func generationFailureStageLabel(stage string) string {
return "任务入队失败"
case "llm_generate":
return "AI 正文生成失败"
case "knowledge_resolve":
return "知识库检索失败"
case "persist_begin_tx":
return "写入生成结果事务启动失败"
case "persist_create_version":
@@ -684,3 +708,29 @@ func nullableJSONString(value []byte) *string {
text := string(value)
return &text
}
func extractKnowledgeGroupIDs(value interface{}) []int64 {
switch typed := value.(type) {
case []int64:
return normalizeInt64IDs(typed)
case []interface{}:
ids := make([]int64, 0, len(typed))
for _, item := range typed {
switch raw := item.(type) {
case int64:
ids = append(ids, raw)
case int32:
ids = append(ids, int64(raw))
case int:
ids = append(ids, int64(raw))
case float64:
ids = append(ids, int64(raw))
case float32:
ids = append(ids, int64(raw))
}
}
return normalizeInt64IDs(ids)
default:
return nil
}
}