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
@@ -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 {