feat(knowledge): broaden context resolution and prompt query
- Walk knowledge group children recursively so selecting a parent group pulls snippets from its descendants too. - Filter knowledge items to status='completed' across the prompt and active-snippet queries to avoid leaking in-progress documents. - Dedupe vector candidates by point id and normalized text, then pick rerank+vector results with a per-item cap so prompts stay diverse. - Tag each rendered snippet with a [Kn] reference id for prompt citing. - Build the rule knowledge query from task params (title/keywords/etc.) before falling back to rule prompt content so retrieval matches the user's actual ask. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/retrieval"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractKnowledgePreciseFactsKeepsAddressAndContactLines(t *testing.T) {
|
func TestExtractKnowledgePreciseFactsKeepsAddressAndContactLines(t *testing.T) {
|
||||||
@@ -43,6 +45,7 @@ func TestRenderPromptSectionIncludesPreciseFactsAndSourceURI(t *testing.T) {
|
|||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"以下为必须严格按原文保留的高精度事实",
|
"以下为必须严格按原文保留的高精度事实",
|
||||||
"- 门店信息 / 合肥门店:地址:合肥市蜀山区望江西路 123 号",
|
"- 门店信息 / 合肥门店:地址:合肥市蜀山区望江西路 123 号",
|
||||||
|
"1. [K1] 门店信息 / 合肥门店",
|
||||||
"来源链接: https://example.com/store",
|
"来源链接: https://example.com/store",
|
||||||
"这里是一段门店介绍。",
|
"这里是一段门店介绍。",
|
||||||
} {
|
} {
|
||||||
@@ -93,6 +96,64 @@ func TestExtractKnowledgePreciseFactsKeepsBusinessScopeSection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKnowledgeSnippetsFromSearchPointsSkipsMissingTextAndDedupes(t *testing.T) {
|
||||||
|
snippets := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints([]retrieval.SearchPoint{
|
||||||
|
{
|
||||||
|
ID: "point-1",
|
||||||
|
Score: 0.91,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"group_id": int64(10),
|
||||||
|
"group_name": "产品资料",
|
||||||
|
"item_id": int64(100),
|
||||||
|
"item_name": "旗舰款",
|
||||||
|
"text": " 支持全屋定制方案 ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "point-2",
|
||||||
|
Payload: map[string]any{
|
||||||
|
"text": "支持全屋定制方案",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "point-3",
|
||||||
|
Payload: map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
if len(snippets) != 1 {
|
||||||
|
t.Fatalf("deduped snippets length = %d, want 1: %#v", len(snippets), snippets)
|
||||||
|
}
|
||||||
|
if snippets[0].Text != "支持全屋定制方案" {
|
||||||
|
t.Fatalf("snippet text = %q, want trimmed text", snippets[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectKnowledgeRerankedSnippetsFillsAndDiversifiesResults(t *testing.T) {
|
||||||
|
candidates := []KnowledgeSnippet{
|
||||||
|
{PointID: "p1", KnowledgeItemID: 1, Text: "同一文档片段 1", Score: 0.90},
|
||||||
|
{PointID: "p2", KnowledgeItemID: 1, Text: "同一文档片段 2", Score: 0.89},
|
||||||
|
{PointID: "p3", KnowledgeItemID: 1, Text: "同一文档片段 3", Score: 0.88},
|
||||||
|
{PointID: "p4", KnowledgeItemID: 2, Text: "第二文档片段", Score: 0.80},
|
||||||
|
{PointID: "p5", KnowledgeItemID: 3, Text: "第三文档片段", Score: 0.70},
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := selectKnowledgeRerankedSnippets(candidates, []retrieval.RerankResult{
|
||||||
|
{Index: 2, RelevanceScore: 0.99},
|
||||||
|
{Index: 99, RelevanceScore: 1},
|
||||||
|
}, 4)
|
||||||
|
|
||||||
|
if len(selected) != 4 {
|
||||||
|
t.Fatalf("selected length = %d, want 4: %#v", len(selected), selected)
|
||||||
|
}
|
||||||
|
if selected[0].PointID != "p3" || selected[0].Score != 0.99 {
|
||||||
|
t.Fatalf("selected[0] = %#v, want reranked p3 with rerank score", selected[0])
|
||||||
|
}
|
||||||
|
if selected[1].PointID != "p1" || selected[2].PointID != "p4" || selected[3].PointID != "p5" {
|
||||||
|
t.Fatalf("selected order = %#v, want reranked result filled with diverse vector candidates", selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func containsExactString(items []string, target string) bool {
|
func containsExactString(items []string, target string) bool {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item == target {
|
if item == target {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
defaultKnowledgeQueueSize = 128
|
defaultKnowledgeQueueSize = 128
|
||||||
defaultKnowledgeCleanupPoll = 15 * time.Second
|
defaultKnowledgeCleanupPoll = 15 * time.Second
|
||||||
defaultKnowledgeChildGroupName = "默认目录"
|
defaultKnowledgeChildGroupName = "默认目录"
|
||||||
|
maxKnowledgeSnippetsPerItem = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type KnowledgeService struct {
|
type KnowledgeService struct {
|
||||||
@@ -743,8 +744,8 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
groupIDs []int64,
|
groupIDs []int64,
|
||||||
query string,
|
query string,
|
||||||
) (*KnowledgeContext, error) {
|
) (*KnowledgeContext, error) {
|
||||||
groupIDs = normalizeInt64IDs(groupIDs)
|
requestedGroupIDs := normalizeInt64IDs(groupIDs)
|
||||||
if len(groupIDs) == 0 {
|
if len(requestedGroupIDs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
@@ -757,19 +758,19 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
return nil, response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
|
return nil, response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
groupNames, err := s.listGroupNames(ctx, tenantID, groupIDs)
|
searchGroupIDs, groupNames, err := s.resolveKnowledgeSearchScope(ctx, tenantID, requestedGroupIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(groupNames) == 0 {
|
if len(searchGroupIDs) == 0 || len(groupNames) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseContext := &KnowledgeContext{
|
baseContext := &KnowledgeContext{
|
||||||
GroupIDs: groupIDs,
|
GroupIDs: searchGroupIDs,
|
||||||
GroupNames: groupNames,
|
GroupNames: groupNames,
|
||||||
Snippets: []KnowledgeSnippet{},
|
Snippets: []KnowledgeSnippet{},
|
||||||
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, nil),
|
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
vectors, err := s.provider.Embed(ctx, []string{query})
|
vectors, err := s.provider.Embed(ctx, []string{query})
|
||||||
@@ -783,7 +784,7 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
|
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
|
||||||
Vector: vectors[0],
|
Vector: vectors[0],
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
GroupIDs: groupIDs,
|
GroupIDs: searchGroupIDs,
|
||||||
Limit: s.recallLimit,
|
Limit: s.recallLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -794,24 +795,7 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates := make([]KnowledgeSnippet, 0, len(searchPoints))
|
candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints))
|
||||||
documents := make([]string, 0, len(searchPoints))
|
|
||||||
for _, point := range searchPoints {
|
|
||||||
text := strings.TrimSpace(anyString(point.Payload["text"]))
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
candidates = append(candidates, KnowledgeSnippet{
|
|
||||||
PointID: point.ID,
|
|
||||||
GroupID: anyInt64(point.Payload["group_id"]),
|
|
||||||
GroupName: strings.TrimSpace(anyString(point.Payload["group_name"])),
|
|
||||||
KnowledgeItemID: anyInt64(point.Payload["item_id"]),
|
|
||||||
ItemName: strings.TrimSpace(anyString(point.Payload["item_name"])),
|
|
||||||
Text: text,
|
|
||||||
Score: point.Score,
|
|
||||||
})
|
|
||||||
documents = append(documents, text)
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "no_candidate_text", nil, nil)
|
s.logKnowledgeResolve(baseContext, tenantID, query, "no_candidate_text", nil, nil)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
@@ -826,37 +810,25 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
documents = documents[:0]
|
documents := make([]string, 0, len(candidates))
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
documents = append(documents, candidate.Text)
|
documents = append(documents, candidate.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents)))
|
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
selected := candidates[:minInt(len(candidates), s.rerankTopN)]
|
selected := selectKnowledgeCandidateSnippets(candidates, s.rerankTopN)
|
||||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||||
baseContext.Snippets = selected
|
baseContext.Snippets = selected
|
||||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
|
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "rerank_failed_fallback", candidates, selected)
|
s.logKnowledgeResolve(baseContext, tenantID, query, "rerank_failed_fallback", candidates, selected)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
selected := make([]KnowledgeSnippet, 0, len(reranked))
|
selected := selectKnowledgeRerankedSnippets(candidates, reranked, s.rerankTopN)
|
||||||
for _, item := range reranked {
|
|
||||||
if item.Index < 0 || item.Index >= len(candidates) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
snippet := candidates[item.Index]
|
|
||||||
snippet.Score = item.RelevanceScore
|
|
||||||
if strings.TrimSpace(item.Text) != "" {
|
|
||||||
snippet.Text = strings.TrimSpace(item.Text)
|
|
||||||
}
|
|
||||||
selected = append(selected, snippet)
|
|
||||||
}
|
|
||||||
|
|
||||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||||
baseContext.Snippets = selected
|
baseContext.Snippets = selected
|
||||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
|
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "resolved", candidates, selected)
|
s.logKnowledgeResolve(baseContext, tenantID, query, "resolved", candidates, selected)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
@@ -948,6 +920,143 @@ func knowledgeLogPreview(value string, maxRunes int) string {
|
|||||||
return strings.TrimSpace(string(runes[:maxRunes])) + "..."
|
return strings.TrimSpace(string(runes[:maxRunes])) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func knowledgeSnippetsFromSearchPoints(points []retrieval.SearchPoint) []KnowledgeSnippet {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return []KnowledgeSnippet{}
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets := make([]KnowledgeSnippet, 0, len(points))
|
||||||
|
for _, point := range points {
|
||||||
|
text := strings.TrimSpace(anyString(point.Payload["text"]))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snippets = append(snippets, KnowledgeSnippet{
|
||||||
|
PointID: strings.TrimSpace(point.ID),
|
||||||
|
GroupID: anyInt64(point.Payload["group_id"]),
|
||||||
|
GroupName: strings.TrimSpace(anyString(point.Payload["group_name"])),
|
||||||
|
KnowledgeItemID: anyInt64(point.Payload["item_id"]),
|
||||||
|
ItemName: strings.TrimSpace(anyString(point.Payload["item_name"])),
|
||||||
|
Text: text,
|
||||||
|
Score: point.Score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return snippets
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeKnowledgeSearchCandidates(candidates []KnowledgeSnippet) []KnowledgeSnippet {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return []KnowledgeSnippet{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPoints := make(map[string]struct{}, len(candidates))
|
||||||
|
seenTexts := make(map[string]struct{}, len(candidates))
|
||||||
|
deduped := make([]KnowledgeSnippet, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if pointID := strings.TrimSpace(candidate.PointID); pointID != "" {
|
||||||
|
if _, exists := seenPoints[pointID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPoints[pointID] = struct{}{}
|
||||||
|
}
|
||||||
|
textKey := normalizeKnowledgeSnippetTextKey(candidate.Text)
|
||||||
|
if textKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seenTexts[textKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTexts[textKey] = struct{}{}
|
||||||
|
candidate.Text = strings.TrimSpace(candidate.Text)
|
||||||
|
deduped = append(deduped, candidate)
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectKnowledgeCandidateSnippets(candidates []KnowledgeSnippet, limit int) []KnowledgeSnippet {
|
||||||
|
return selectKnowledgeRerankedSnippets(candidates, nil, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectKnowledgeRerankedSnippets(
|
||||||
|
candidates []KnowledgeSnippet,
|
||||||
|
reranked []retrieval.RerankResult,
|
||||||
|
limit int,
|
||||||
|
) []KnowledgeSnippet {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return []KnowledgeSnippet{}
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > len(candidates) {
|
||||||
|
limit = len(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]KnowledgeSnippet, 0, limit)
|
||||||
|
seenPoints := make(map[string]struct{}, limit)
|
||||||
|
seenTexts := make(map[string]struct{}, limit)
|
||||||
|
itemCounts := make(map[int64]int, limit)
|
||||||
|
|
||||||
|
appendSnippet := func(snippet KnowledgeSnippet, enforceItemLimit bool) {
|
||||||
|
if len(selected) >= limit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pointID := strings.TrimSpace(snippet.PointID); pointID != "" {
|
||||||
|
if _, exists := seenPoints[pointID]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textKey := normalizeKnowledgeSnippetTextKey(snippet.Text)
|
||||||
|
if textKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := seenTexts[textKey]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if enforceItemLimit && snippet.KnowledgeItemID > 0 && itemCounts[snippet.KnowledgeItemID] >= maxKnowledgeSnippetsPerItem {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet.Text = strings.TrimSpace(snippet.Text)
|
||||||
|
selected = append(selected, snippet)
|
||||||
|
if pointID := strings.TrimSpace(snippet.PointID); pointID != "" {
|
||||||
|
seenPoints[pointID] = struct{}{}
|
||||||
|
}
|
||||||
|
seenTexts[textKey] = struct{}{}
|
||||||
|
if snippet.KnowledgeItemID > 0 {
|
||||||
|
itemCounts[snippet.KnowledgeItemID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range reranked {
|
||||||
|
if item.Index < 0 || item.Index >= len(candidates) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snippet := candidates[item.Index]
|
||||||
|
snippet.Score = item.RelevanceScore
|
||||||
|
if text := strings.TrimSpace(item.Text); text != "" {
|
||||||
|
snippet.Text = text
|
||||||
|
}
|
||||||
|
appendSnippet(snippet, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
appendSnippet(candidate, true)
|
||||||
|
}
|
||||||
|
if len(selected) < limit {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
appendSnippet(candidate, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeSnippetTextKey(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.Join(strings.Fields(text), " "))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||||
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0) {
|
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0) {
|
||||||
return ""
|
return ""
|
||||||
@@ -970,7 +1079,8 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
|||||||
|
|
||||||
for index, snippet := range ctx.Snippets {
|
for index, snippet := range ctx.Snippets {
|
||||||
label := knowledgeSnippetPromptLabel(snippet, true, index)
|
label := knowledgeSnippetPromptLabel(snippet, true, index)
|
||||||
block := fmt.Sprintf("%d. %s\n%s", index+1, label, snippet.Text)
|
referenceID := knowledgeSnippetReferenceID(index)
|
||||||
|
block := fmt.Sprintf("%d. [%s] %s\n%s", index+1, referenceID, label, snippet.Text)
|
||||||
if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" {
|
if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" {
|
||||||
block = fmt.Sprintf("%s\n来源链接: %s", block, sourceURI)
|
block = fmt.Sprintf("%s\n来源链接: %s", block, sourceURI)
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1169,7 @@ func (s *KnowledgeService) loadKnowledgePromptItems(
|
|||||||
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
||||||
WHERE ki.tenant_id = $1
|
WHERE ki.tenant_id = $1
|
||||||
AND ki.id = ANY($2::bigint[])
|
AND ki.id = ANY($2::bigint[])
|
||||||
|
AND ki.status = 'completed'
|
||||||
AND ki.deleted_at IS NULL
|
AND ki.deleted_at IS NULL
|
||||||
`, tenantID, itemIDs)
|
`, tenantID, itemIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1188,6 +1299,7 @@ func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs(
|
|||||||
FROM knowledge_items ki
|
FROM knowledge_items ki
|
||||||
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
||||||
WHERE ki.tenant_id = $1
|
WHERE ki.tenant_id = $1
|
||||||
|
AND ki.status = 'completed'
|
||||||
AND ki.deleted_at IS NULL
|
AND ki.deleted_at IS NULL
|
||||||
AND ki.group_id IN (SELECT id FROM selected_groups)
|
AND ki.group_id IN (SELECT id FROM selected_groups)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -1264,6 +1376,10 @@ func knowledgeSnippetPromptLabel(snippet KnowledgeSnippet, fallbackToDefault boo
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func knowledgeSnippetReferenceID(index int) string {
|
||||||
|
return fmt.Sprintf("K%d", index+1)
|
||||||
|
}
|
||||||
|
|
||||||
func extractKnowledgePreciseFacts(text string) []string {
|
func extractKnowledgePreciseFacts(text string) []string {
|
||||||
normalized := normalizeKnowledgeMarkdown(text)
|
normalized := normalizeKnowledgeMarkdown(text)
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
@@ -2323,6 +2439,7 @@ func (s *KnowledgeService) filterActiveKnowledgeSnippets(
|
|||||||
FROM knowledge_items
|
FROM knowledge_items
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND id = ANY($2::bigint[])
|
AND id = ANY($2::bigint[])
|
||||||
|
AND status = 'completed'
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
`, tenantID, itemIDs)
|
`, tenantID, itemIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2622,37 +2739,62 @@ func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KnowledgeService) listGroupNames(ctx context.Context, tenantID int64, groupIDs []int64) ([]string, error) {
|
func (s *KnowledgeService) resolveKnowledgeSearchScope(ctx context.Context, tenantID int64, groupIDs []int64) ([]int64, []string, error) {
|
||||||
groupIDs = normalizeInt64IDs(groupIDs)
|
groupIDs = normalizeInt64IDs(groupIDs)
|
||||||
if len(groupIDs) == 0 {
|
if len(groupIDs) == 0 {
|
||||||
return []string{}, nil
|
return []int64{}, []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
WITH RECURSIVE selected_groups AS (
|
||||||
|
SELECT id, name, parent_id, sort_order, created_at
|
||||||
|
FROM knowledge_groups
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND id = ANY($2::bigint[])
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT child.id, child.name, child.parent_id, child.sort_order, child.created_at
|
||||||
|
FROM knowledge_groups child
|
||||||
|
JOIN selected_groups parent ON child.parent_id = parent.id
|
||||||
|
WHERE child.tenant_id = $1
|
||||||
|
AND child.deleted_at IS NULL
|
||||||
|
)
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM knowledge_groups
|
FROM selected_groups
|
||||||
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
|
|
||||||
ORDER BY sort_order, created_at, id
|
ORDER BY sort_order, created_at, id
|
||||||
`, tenantID, groupIDs)
|
`, tenantID, groupIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
|
return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
resolvedIDs := make([]int64, 0, len(groupIDs))
|
||||||
names := make([]string, 0, len(groupIDs))
|
names := make([]string, 0, len(groupIDs))
|
||||||
|
seenIDs := make(map[int64]struct{}, len(groupIDs))
|
||||||
|
seenNames := make(map[string]struct{}, len(groupIDs))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
groupID int64
|
groupID int64
|
||||||
name string
|
name string
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&groupID, &name); err != nil {
|
if err := rows.Scan(&groupID, &name); err != nil {
|
||||||
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error())
|
return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(name) != "" {
|
if groupID > 0 {
|
||||||
names = append(names, strings.TrimSpace(name))
|
if _, exists := seenIDs[groupID]; !exists {
|
||||||
|
seenIDs[groupID] = struct{}{}
|
||||||
|
resolvedIDs = append(resolvedIDs, groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
if _, exists := seenNames[name]; !exists {
|
||||||
|
seenNames[name] = struct{}{}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return names, nil
|
return resolvedIDs, names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeInt64IDs(ids []int64) []int64 {
|
func normalizeInt64IDs(ids []int64) []int64 {
|
||||||
@@ -2677,8 +2819,12 @@ func normalizeInt64IDs(ids []int64) []int64 {
|
|||||||
|
|
||||||
func anyString(value any) string {
|
func anyString(value any) string {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
case string:
|
case string:
|
||||||
return typed
|
return typed
|
||||||
|
case []byte:
|
||||||
|
return string(typed)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%v", typed)
|
return fmt.Sprintf("%v", typed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -657,16 +657,35 @@ func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[
|
|||||||
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
||||||
parts = append(parts, 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 != "" {
|
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
|
||||||
parts = append(parts, taskName)
|
parts = append(parts, taskName)
|
||||||
}
|
}
|
||||||
|
for _, key := range []string{
|
||||||
|
"title",
|
||||||
|
"topic",
|
||||||
|
"subject",
|
||||||
|
"brand_name",
|
||||||
|
"product_name",
|
||||||
|
"primary_keyword",
|
||||||
|
"key_points",
|
||||||
|
"extra_requirements",
|
||||||
|
} {
|
||||||
|
if text := strings.TrimSpace(extractString(params, key)); text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keywords := extractStringList(params["keywords"], 16); len(keywords) > 0 {
|
||||||
|
parts = append(parts, strings.Join(keywords, "\n"))
|
||||||
|
}
|
||||||
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
|
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
|
||||||
parts = append(parts, target)
|
parts = append(parts, target)
|
||||||
}
|
}
|
||||||
|
if rule != nil {
|
||||||
|
if text := strings.TrimSpace(rule.PromptContent); text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user