diff --git a/deploy/k3s/config/prompts.yml b/deploy/k3s/config/prompts.yml index da9bb38..e578db9 100644 --- a/deploy/k3s/config/prompts.yml +++ b/deploy/k3s/config/prompts.yml @@ -317,9 +317,10 @@ runtime: avoid_points: "需要规避" extra_requirements: "其他要求" knowledge_prompt_intro_lines: - - "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:" - - "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。" - - "- 若知识库内容与用户明确输入冲突,以用户输入为准。" + - "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:" + - "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。" + - "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。" + - "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。" - "- 信息不足时不要虚构事实。" knowledge_snippet_label_format: "知识片段 %d" knowledge_website_markdown: diff --git a/deploy/prompts.yml b/deploy/prompts.yml index da9bb38..e578db9 100644 --- a/deploy/prompts.yml +++ b/deploy/prompts.yml @@ -317,9 +317,10 @@ runtime: avoid_points: "需要规避" extra_requirements: "其他要求" knowledge_prompt_intro_lines: - - "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:" - - "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。" - - "- 若知识库内容与用户明确输入冲突,以用户输入为准。" + - "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:" + - "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。" + - "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。" + - "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。" - "- 信息不足时不要虚构事实。" knowledge_snippet_label_format: "知识片段 %d" knowledge_website_markdown: diff --git a/server/configs/prompts.yml b/server/configs/prompts.yml index da9bb38..e578db9 100644 --- a/server/configs/prompts.yml +++ b/server/configs/prompts.yml @@ -317,9 +317,10 @@ runtime: avoid_points: "需要规避" extra_requirements: "其他要求" knowledge_prompt_intro_lines: - - "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:" - - "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。" - - "- 若知识库内容与用户明确输入冲突,以用户输入为准。" + - "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:" + - "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。" + - "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。" + - "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。" - "- 信息不足时不要虚构事实。" knowledge_snippet_label_format: "知识片段 %d" knowledge_website_markdown: diff --git a/server/internal/tenant/app/knowledge_fact_guard.go b/server/internal/tenant/app/knowledge_fact_guard.go new file mode 100644 index 0000000..b6fd832 --- /dev/null +++ b/server/internal/tenant/app/knowledge_fact_guard.go @@ -0,0 +1,537 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/geo-platform/tenant-api/internal/shared/llm" +) + +type KnowledgeFactKind string + +const ( + KnowledgeFactKindCompanyFoundingDate KnowledgeFactKind = "company_founding_date" + KnowledgeFactKindCompanyExperienceYears KnowledgeFactKind = "company_experience_years" +) + +var ErrKnowledgeFactConflict = errors.New("generated article contradicts canonical knowledge facts") + +type KnowledgeFactConstraint struct { + Kind KnowledgeFactKind `json:"kind"` + Subject string `json:"subject"` + Aliases []string `json:"aliases,omitempty"` + Value string `json:"value"` + SourceText string `json:"source_text"` +} + +type KnowledgeFactConflict struct { + Kind KnowledgeFactKind + Subject string + CanonicalValue string + ConflictingValue string + ConflictingFact string +} + +type KnowledgeFactViolation struct { + Kind KnowledgeFactKind + Subject string + Expected string + Actual string + Excerpt string +} + +type KnowledgeFactGuardError struct { + Violations []KnowledgeFactViolation +} + +func (e *KnowledgeFactGuardError) Error() string { + if e == nil || len(e.Violations) == 0 { + return ErrKnowledgeFactConflict.Error() + } + parts := make([]string, 0, len(e.Violations)) + for _, violation := range e.Violations { + parts = append(parts, fmt.Sprintf("%s/%s expected %s, got %s", violation.Subject, violation.Kind, violation.Expected, violation.Actual)) + } + return ErrKnowledgeFactConflict.Error() + ": " + strings.Join(parts, "; ") +} + +func (e *KnowledgeFactGuardError) Unwrap() error { + return ErrKnowledgeFactConflict +} + +var ( + knowledgeFactSubjectPattern = regexp.MustCompile(`([\p{Han}A-Za-z0-9·()()]{2,64}(?:有限责任公司|股份有限公司|有限公司|公司|品牌|家居))\s*(?:正式)?(?:注册成立|成立|创立|创办|创建)`) + knowledgeFactFoundingPattern = regexp.MustCompile(`(?:注册成立|成立|创立|创办|创建)(?:时间)?\s*(?:于|为|是)?\s*((?:19|20)\d{2})\s*年?(?:\s*(\d{1,2})\s*月)?`) + knowledgeFactExperiencePattern = regexp.MustCompile(`(\d{1,3})\s*年(?:以上)?(?:的)?(?:本地)?(?:服务|行业|从业|经营|实战|设计|施工|定制)?(?:经验|历程|沉淀)`) + knowledgeFactDeepExperiencePattern = regexp.MustCompile(`(?:深耕|从业|经营)\D{0,12}(\d{1,3})\s*年`) + percentEncodedKnowledgeTextPattern = regexp.MustCompile(`%[0-9A-Fa-f]{2}`) +) + +func buildKnowledgeFactConstraints(facts []string) ([]KnowledgeFactConstraint, []KnowledgeFactConflict) { + constraints := make([]KnowledgeFactConstraint, 0, len(facts)) + conflicts := make([]KnowledgeFactConflict, 0) + + for _, fact := range facts { + for _, candidate := range parseKnowledgeFactConstraints(fact) { + matched := -1 + for index := range constraints { + if constraints[index].Kind == candidate.Kind && knowledgeFactSubjectsEquivalent(constraints[index].Subject, candidate.Subject) { + matched = index + break + } + } + if matched < 0 { + constraints = append(constraints, candidate) + continue + } + if constraints[matched].Value == candidate.Value { + constraints[matched].Aliases = mergeKnowledgeFactAliases(constraints[matched].Aliases, candidate.Aliases) + continue + } + conflicts = append(conflicts, KnowledgeFactConflict{ + Kind: candidate.Kind, + Subject: constraints[matched].Subject, + CanonicalValue: constraints[matched].Value, + ConflictingValue: candidate.Value, + ConflictingFact: candidate.SourceText, + }) + } + } + + return constraints, conflicts +} + +func MergeKnowledgeFactConstraints( + primary []KnowledgeFactConstraint, + fallback []KnowledgeFactConstraint, +) []KnowledgeFactConstraint { + merged := append([]KnowledgeFactConstraint{}, primary...) + for _, candidate := range fallback { + matched := -1 + for index := range merged { + if merged[index].Kind == candidate.Kind && knowledgeFactSubjectsEquivalent(merged[index].Subject, candidate.Subject) { + matched = index + break + } + } + if matched < 0 { + merged = append(merged, candidate) + continue + } + merged[matched].Aliases = mergeKnowledgeFactAliases(merged[matched].Aliases, candidate.Aliases) + } + return merged +} + +func parseKnowledgeFactConstraints(fact string) []KnowledgeFactConstraint { + fact = strings.TrimSpace(fact) + if fact == "" { + return nil + } + + subject := extractKnowledgeFactSubject(fact) + if subject == "" { + return nil + } + aliases := buildKnowledgeFactAliases(subject, fact) + constraints := make([]KnowledgeFactConstraint, 0, 2) + + if match := knowledgeFactFoundingPattern.FindStringSubmatch(fact); len(match) > 1 { + constraints = append(constraints, KnowledgeFactConstraint{ + Kind: KnowledgeFactKindCompanyFoundingDate, + Subject: subject, + Aliases: aliases, + Value: normalizeKnowledgeDateValue(match[1], submatchValue(match, 2)), + SourceText: fact, + }) + } + + if years := extractKnowledgeExperienceYears(fact); years != "" { + constraints = append(constraints, KnowledgeFactConstraint{ + Kind: KnowledgeFactKindCompanyExperienceYears, + Subject: subject, + Aliases: aliases, + Value: years, + SourceText: fact, + }) + } + + return constraints +} + +func extractKnowledgeFactSubject(fact string) string { + match := knowledgeFactSubjectPattern.FindStringSubmatch(fact) + if len(match) < 2 { + return "" + } + subject := strings.TrimSpace(match[1]) + for { + separator := strings.LastIndexAny(subject, "::/||") + if separator < 0 { + break + } + subject = strings.TrimSpace(subject[separator+1:]) + } + return strings.Trim(subject, "-—_ ,,。;;::") +} + +func buildKnowledgeFactAliases(subject string, fact string) []string { + aliases := []string{strings.TrimSpace(subject)} + trimmedSubject := strings.TrimSpace(subject) + for _, suffix := range []string{"有限责任公司", "股份有限公司", "有限公司", "公司"} { + if strings.HasSuffix(trimmedSubject, suffix) { + aliases = append(aliases, strings.TrimSpace(strings.TrimSuffix(trimmedSubject, suffix))) + break + } + } + + if separator := strings.IndexAny(fact, "::"); separator > 0 { + label := strings.TrimSpace(fact[:separator]) + if slash := strings.LastIndexAny(label, "/||"); slash >= 0 { + label = strings.TrimSpace(label[slash+1:]) + } + for _, suffix := range []string{"公司简介", "企业简介", "品牌简介", "简介", "介绍", "资料"} { + label = strings.TrimSpace(strings.TrimSuffix(label, suffix)) + } + if len([]rune(label)) >= 4 { + aliases = append(aliases, label) + } + } + + return normalizeKnowledgeFactAliases(aliases) +} + +func normalizeKnowledgeDateValue(year string, month string) string { + year = strings.TrimSpace(year) + month = strings.TrimSpace(month) + if month == "" { + return year + } + monthValue, err := strconv.Atoi(month) + if err != nil || monthValue < 1 || monthValue > 12 { + return year + } + return fmt.Sprintf("%s-%02d", year, monthValue) +} + +func extractKnowledgeExperienceYears(text string) string { + for _, pattern := range []*regexp.Regexp{knowledgeFactExperiencePattern, knowledgeFactDeepExperiencePattern} { + if match := pattern.FindStringSubmatch(text); len(match) > 1 { + return strings.TrimSpace(match[1]) + } + } + return "" +} + +func knowledgeFactSubjectsEquivalent(left string, right string) bool { + leftKey := normalizeKnowledgeFactComparable(left) + rightKey := normalizeKnowledgeFactComparable(right) + if leftKey == "" || rightKey == "" { + return false + } + if leftKey == rightKey { + return true + } + shorter := leftKey + longer := rightKey + if len([]rune(shorter)) > len([]rune(longer)) { + shorter, longer = longer, shorter + } + return len([]rune(shorter)) >= 4 && strings.Contains(longer, shorter) +} + +func normalizeKnowledgeFactComparable(value string) string { + var builder strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + builder.WriteRune(r) + } + } + return builder.String() +} + +func normalizeKnowledgeFactAliases(values []string) []string { + seen := make(map[string]struct{}, len(values)) + aliases := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + key := normalizeKnowledgeFactComparable(value) + if value == "" || len([]rune(key)) < 4 { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, value) + } + sort.SliceStable(aliases, func(i, j int) bool { + return len([]rune(aliases[i])) > len([]rune(aliases[j])) + }) + return aliases +} + +func mergeKnowledgeFactAliases(left []string, right []string) []string { + return normalizeKnowledgeFactAliases(append(append([]string{}, left...), right...)) +} + +func removeConflictingKnowledgeFacts(facts []string, conflicts []KnowledgeFactConflict) []string { + if len(facts) == 0 || len(conflicts) == 0 { + return facts + } + ignored := make(map[string]struct{}, len(conflicts)) + for _, conflict := range conflicts { + ignored[conflict.ConflictingFact] = struct{}{} + } + filtered := make([]string, 0, len(facts)) + for _, fact := range facts { + if _, exists := ignored[fact]; exists { + continue + } + filtered = append(filtered, fact) + } + return filtered +} + +func ValidateGeneratedKnowledgeFacts(content string, constraints []KnowledgeFactConstraint) []KnowledgeFactViolation { + if strings.TrimSpace(content) == "" || len(constraints) == 0 { + return nil + } + content = decodeKnowledgeFactContent(content) + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + violations := make([]KnowledgeFactViolation, 0) + seen := make(map[string]struct{}) + currentHeading := "" + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + currentHeading = strings.TrimSpace(strings.TrimLeft(line, "#")) + } + scope := strings.TrimSpace(currentHeading + " " + line) + + for _, constraint := range constraints { + if !knowledgeFactScopeMatches(scope, constraint) { + continue + } + switch constraint.Kind { + case KnowledgeFactKindCompanyFoundingDate: + for _, match := range knowledgeFactFoundingPattern.FindAllStringSubmatch(line, -1) { + actual := normalizeKnowledgeDateValue(submatchValue(match, 1), submatchValue(match, 2)) + if knowledgeDateMatchesConstraint(actual, constraint.Value) { + continue + } + appendKnowledgeFactViolation(&violations, seen, KnowledgeFactViolation{ + Kind: constraint.Kind, + Subject: constraint.Subject, + Expected: constraint.Value, + Actual: actual, + Excerpt: line, + }) + } + } + } + + for _, founding := range constraintsByKind(constraints, KnowledgeFactKindCompanyFoundingDate) { + if !knowledgeFactScopeMatches(scope, founding) { + continue + } + actualYears := extractKnowledgeExperienceYears(line) + if actualYears == "" { + continue + } + experience := matchingKnowledgeFactConstraint(constraints, KnowledgeFactKindCompanyExperienceYears, founding.Subject) + if experience != nil && experience.Value == actualYears { + continue + } + expected := "知识库未提供该年限" + if experience != nil { + expected = experience.Value + } + appendKnowledgeFactViolation(&violations, seen, KnowledgeFactViolation{ + Kind: KnowledgeFactKindCompanyExperienceYears, + Subject: founding.Subject, + Expected: expected, + Actual: actualYears, + Excerpt: line, + }) + } + } + + return violations +} + +func decodeKnowledgeFactContent(content string) string { + if !percentEncodedKnowledgeTextPattern.MatchString(content) { + return content + } + decoded, err := url.PathUnescape(content) + if err != nil { + return content + } + return decoded +} + +func knowledgeFactScopeMatches(scope string, constraint KnowledgeFactConstraint) bool { + scopeKey := normalizeKnowledgeFactComparable(scope) + if scopeKey == "" { + return false + } + aliases := append([]string{constraint.Subject}, constraint.Aliases...) + for _, alias := range aliases { + aliasKey := normalizeKnowledgeFactComparable(alias) + if len([]rune(aliasKey)) >= 4 && strings.Contains(scopeKey, aliasKey) { + return true + } + } + return false +} + +func knowledgeDateMatchesConstraint(actual string, expected string) bool { + if actual == expected { + return true + } + actualYear, _, _ := strings.Cut(actual, "-") + expectedYear, _, _ := strings.Cut(expected, "-") + return actualYear == expectedYear && !strings.Contains(actual, "-") +} + +func constraintsByKind(constraints []KnowledgeFactConstraint, kind KnowledgeFactKind) []KnowledgeFactConstraint { + filtered := make([]KnowledgeFactConstraint, 0) + for _, constraint := range constraints { + if constraint.Kind == kind { + filtered = append(filtered, constraint) + } + } + return filtered +} + +func matchingKnowledgeFactConstraint( + constraints []KnowledgeFactConstraint, + kind KnowledgeFactKind, + subject string, +) *KnowledgeFactConstraint { + for index := range constraints { + if constraints[index].Kind == kind && knowledgeFactSubjectsEquivalent(constraints[index].Subject, subject) { + return &constraints[index] + } + } + return nil +} + +func appendKnowledgeFactViolation( + violations *[]KnowledgeFactViolation, + seen map[string]struct{}, + violation KnowledgeFactViolation, +) { + key := strings.Join([]string{string(violation.Kind), violation.Subject, violation.Expected, violation.Actual, violation.Excerpt}, "\x00") + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + *violations = append(*violations, violation) +} + +func GenerateArticleWithKnowledgeFactGuard( + ctx context.Context, + client llm.Client, + req llm.GenerateRequest, + constraints []KnowledgeFactConstraint, + onDelta func(string), +) (*llm.GenerateResult, error) { + if len(constraints) == 0 { + return client.Generate(ctx, req, onDelta) + } + + result, err := client.Generate(ctx, req, nil) + if err != nil { + return nil, err + } + if result == nil { + return nil, errors.New("knowledge fact guarded generation returned no result") + } + violations := ValidateGeneratedKnowledgeFacts(result.Content, constraints) + if len(violations) == 0 { + return result, nil + } + + repairReq := req + repairReq.Prompt = buildKnowledgeFactRepairPrompt(req.Prompt, result.Content, constraints, violations) + repaired, err := client.Generate(ctx, repairReq, nil) + if err != nil { + return nil, err + } + if repaired == nil { + return nil, errors.New("knowledge fact repair returned no result") + } + remaining := ValidateGeneratedKnowledgeFacts(repaired.Content, constraints) + if len(remaining) > 0 { + return nil, &KnowledgeFactGuardError{Violations: remaining} + } + return repaired, nil +} + +func buildKnowledgeFactRepairPrompt( + originalPrompt string, + draft string, + constraints []KnowledgeFactConstraint, + violations []KnowledgeFactViolation, +) string { + var builder strings.Builder + builder.WriteString(strings.TrimSpace(originalPrompt)) + builder.WriteString("\n\n# 强制事实纠错\n") + builder.WriteString("上一版草稿违反了品牌资料/知识库规范事实。请输出完整的修正版 Markdown,不要解释修改过程。规范事实优先于草稿、常识、推测和搜索结果;不得新增品牌资料或知识库未明确提供的服务年限。\n\n") + builder.WriteString("## 规范事实\n") + for _, constraint := range constraints { + builder.WriteString("- ") + builder.WriteString(renderKnowledgeFactConstraint(constraint)) + builder.WriteByte('\n') + } + builder.WriteString("\n## 已检测到的冲突\n") + for _, violation := range violations { + builder.WriteString(fmt.Sprintf("- %s:应为 %s,草稿写成 %s\n", violation.Subject, violation.Expected, violation.Actual)) + } + builder.WriteString("\n## 待修正草稿\n") + builder.WriteString(strings.TrimSpace(draft)) + return builder.String() +} + +func renderKnowledgeFactConstraint(constraint KnowledgeFactConstraint) string { + switch constraint.Kind { + case KnowledgeFactKindCompanyFoundingDate: + return fmt.Sprintf("%s:成立时间=%s", constraint.Subject, renderKnowledgeDateValue(constraint.Value)) + case KnowledgeFactKindCompanyExperienceYears: + return fmt.Sprintf("%s:明确服务/从业年限=%s年", constraint.Subject, constraint.Value) + default: + return fmt.Sprintf("%s:%s=%s", constraint.Subject, constraint.Kind, constraint.Value) + } +} + +func renderKnowledgeDateValue(value string) string { + year, month, found := strings.Cut(value, "-") + if !found { + return strings.TrimSpace(value) + "年" + } + monthValue, err := strconv.Atoi(month) + if err != nil { + return year + "年" + } + return fmt.Sprintf("%s年%d月", year, monthValue) +} + +func submatchValue(match []string, index int) string { + if index < 0 || index >= len(match) { + return "" + } + return match[index] +} diff --git a/server/internal/tenant/app/knowledge_fact_guard_test.go b/server/internal/tenant/app/knowledge_fact_guard_test.go new file mode 100644 index 0000000..a76c77d --- /dev/null +++ b/server/internal/tenant/app/knowledge_fact_guard_test.go @@ -0,0 +1,234 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/geo-platform/tenant-api/internal/shared/llm" +) + +func TestExtractKnowledgePreciseFactsKeepsFoundingDateAndExplicitExperience(t *testing.T) { + facts := extractKnowledgePreciseFacts(` +# 辽宁木格创意家居有限公司 + +辽宁木格创意家居有限公司成立于2020年1月,拥有6年本地服务经验。 +普通介绍段落。 +`) + + want := "辽宁木格创意家居有限公司成立于2020年1月,拥有6年本地服务经验。" + if !containsExactString(facts, want) { + t.Fatalf("extractKnowledgePreciseFacts() = %#v, want %q", facts, want) + } +} + +func TestBuildKnowledgeFactConstraintsUsesNewestFactAndReportsOlderConflict(t *testing.T) { + facts := []string{ + "默认目录 / 最新简介:辽宁木格创意家居有限公司成立于2020年1月,深耕定制家居领域。", + "默认目录 / 旧版简介:辽宁木格创意家居有限公司成立于2019年,目前是一家现代化企业。", + } + + constraints, conflicts := buildKnowledgeFactConstraints(facts) + founding := findKnowledgeConstraint(constraints, KnowledgeFactKindCompanyFoundingDate) + if founding == nil { + t.Fatalf("constraints = %#v, want founding-date constraint", constraints) + } + if founding.Value != "2020-01" { + t.Fatalf("founding value = %q, want 2020-01", founding.Value) + } + if founding.Subject != "辽宁木格创意家居有限公司" { + t.Fatalf("founding subject = %q, want company name", founding.Subject) + } + if len(conflicts) != 1 || conflicts[0].CanonicalValue != "2020-01" || conflicts[0].ConflictingValue != "2019" { + t.Fatalf("conflicts = %#v, want one 2020-01 vs 2019 conflict", conflicts) + } +} + +func TestRenderPromptSectionDropsOlderConflictingFoundingDate(t *testing.T) { + output := (&KnowledgeService{}).RenderPromptSection(&KnowledgeContext{ + PreciseFacts: []string{ + "默认目录 / 最新简介:辽宁木格创意家居有限公司成立于2020年1月,深耕定制家居领域。", + "默认目录 / 旧版简介:辽宁木格创意家居有限公司成立于2019年,目前是一家现代化企业。", + }, + }) + + if !strings.Contains(output, "辽宁木格创意家居有限公司:成立时间=2020年1月") { + t.Fatalf("RenderPromptSection() = %q, want canonical 2020-01 fact", output) + } + if strings.Contains(output, "成立于2019年") { + t.Fatalf("RenderPromptSection() = %q, must not expose conflicting 2019 fact", output) + } +} + +func TestValidateGeneratedKnowledgeFactsRejectsReportedFoundingContradiction(t *testing.T) { + constraints := mugeKnowledgeConstraints() + content := ` +## 1. 第一名:辽宁木格创意家居有限公司|评分9.8分 + +- 公司介绍:品牌成立于2014年,拥有12年本地服务经验,自有6000㎡生产基地。 +` + + violations := ValidateGeneratedKnowledgeFacts(content, constraints) + assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyFoundingDate, "2020-01", "2014") + assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyExperienceYears, "知识库未提供该年限", "12") +} + +func TestValidateGeneratedKnowledgeFactsHandlesPercentEncodedMarkdown(t *testing.T) { + content := `## 第一名:辽宁木格创意家居有限公司 +品牌成立于2014年,拥有12年本地服务经验。` + + violations := ValidateGeneratedKnowledgeFacts(url.PathEscape(content), mugeKnowledgeConstraints()) + assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyFoundingDate, "2020-01", "2014") + assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyExperienceYears, "知识库未提供该年限", "12") +} + +func TestValidateGeneratedKnowledgeFactsAcceptsCanonicalDateAndIgnoresOtherSubjects(t *testing.T) { + content := ` +## 第一名:辽宁木格创意家居有限公司 +辽宁木格创意家居有限公司成立于2020年1月。 + +## 第二名:葫芦岛示例家居有限公司 +该公司成立于2014年。 +` + + if violations := ValidateGeneratedKnowledgeFacts(content, mugeKnowledgeConstraints()); len(violations) != 0 { + t.Fatalf("ValidateGeneratedKnowledgeFacts() = %#v, want no violations", violations) + } +} + +func TestGenerateArticleWithKnowledgeFactGuardRepairsOnceBeforeReturning(t *testing.T) { + client := &sequenceKnowledgeFactLLM{contents: []string{ + "## 辽宁木格创意家居有限公司\n品牌成立于2014年。", + "## 辽宁木格创意家居有限公司\n品牌成立于2020年1月。", + }} + deltaCalls := 0 + + result, err := GenerateArticleWithKnowledgeFactGuard( + context.Background(), + client, + llm.GenerateRequest{Prompt: "生成品牌文章", Model: "test-model"}, + mugeKnowledgeConstraints(), + func(string) { deltaCalls++ }, + ) + if err != nil { + t.Fatalf("GenerateArticleWithKnowledgeFactGuard() error = %v", err) + } + if !strings.Contains(result.Content, "2020年1月") { + t.Fatalf("result content = %q, want repaired founding date", result.Content) + } + if len(client.requests) != 2 { + t.Fatalf("Generate calls = %d, want 2", len(client.requests)) + } + if deltaCalls != 0 || client.nonNilCallbacks != 0 { + t.Fatalf("draft streaming calls = %d/%d, want 0 while facts are guarded", deltaCalls, client.nonNilCallbacks) + } + if !strings.Contains(client.requests[1].Prompt, "2020年1月") || !strings.Contains(client.requests[1].Prompt, "2014年") { + t.Fatalf("repair prompt = %q, want canonical fact and invalid draft", client.requests[1].Prompt) + } + if !strings.Contains(client.requests[1].Prompt, "品牌资料/知识库规范事实") { + t.Fatalf("repair prompt = %q, want brand and knowledge fact authority", client.requests[1].Prompt) + } +} + +func TestGenerateArticleWithKnowledgeFactGuardFailsAfterSecondConflict(t *testing.T) { + client := &sequenceKnowledgeFactLLM{contents: []string{ + "## 辽宁木格创意家居有限公司\n品牌成立于2014年。", + "## 辽宁木格创意家居有限公司\n品牌成立于2018年。", + }} + + _, err := GenerateArticleWithKnowledgeFactGuard( + context.Background(), + client, + llm.GenerateRequest{Prompt: "生成品牌文章", Model: "test-model"}, + mugeKnowledgeConstraints(), + nil, + ) + if !errors.Is(err, ErrKnowledgeFactConflict) { + t.Fatalf("error = %v, want ErrKnowledgeFactConflict", err) + } + if len(client.requests) != 2 { + t.Fatalf("Generate calls = %d, want 2", len(client.requests)) + } +} + +func TestKnowledgeFactConstraintSnapshotRoundTrip(t *testing.T) { + payload := attachKnowledgeFactConstraints(map[string]interface{}{}, mugeKnowledgeConstraints()) + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := extractKnowledgeFactConstraints(decoded); !reflect.DeepEqual(got, mugeKnowledgeConstraints()) { + t.Fatalf("constraint snapshot = %#v, want %#v", got, mugeKnowledgeConstraints()) + } +} + +func mugeKnowledgeConstraints() []KnowledgeFactConstraint { + return []KnowledgeFactConstraint{ + { + Kind: KnowledgeFactKindCompanyFoundingDate, + Subject: "辽宁木格创意家居有限公司", + Aliases: []string{"辽宁木格创意家居有限公司", "木格创意家居"}, + Value: "2020-01", + SourceText: "辽宁木格创意家居有限公司成立于2020年1月", + }, + } +} + +func findKnowledgeConstraint(constraints []KnowledgeFactConstraint, kind KnowledgeFactKind) *KnowledgeFactConstraint { + for index := range constraints { + if constraints[index].Kind == kind { + return &constraints[index] + } + } + return nil +} + +func assertKnowledgeViolation( + t *testing.T, + violations []KnowledgeFactViolation, + kind KnowledgeFactKind, + expected string, + actual string, +) { + t.Helper() + for _, violation := range violations { + if violation.Kind == kind && violation.Expected == expected && violation.Actual == actual { + return + } + } + t.Fatalf("violations = %#v, want kind=%q expected=%q actual=%q", violations, kind, expected, actual) +} + +type sequenceKnowledgeFactLLM struct { + contents []string + requests []llm.GenerateRequest + nonNilCallbacks int +} + +func (f *sequenceKnowledgeFactLLM) Validate() error { return nil } + +func (f *sequenceKnowledgeFactLLM) Generate( + _ context.Context, + req llm.GenerateRequest, + onDelta func(string), +) (*llm.GenerateResult, error) { + f.requests = append(f.requests, req) + if onDelta != nil { + f.nonNilCallbacks++ + onDelta("draft") + } + index := len(f.requests) - 1 + if index >= len(f.contents) { + return nil, errors.New("unexpected generate call") + } + return &llm.GenerateResult{Content: f.contents[index], Model: req.Model}, nil +} diff --git a/server/internal/tenant/app/knowledge_service.go b/server/internal/tenant/app/knowledge_service.go index 68cb172..3c5faad 100644 --- a/server/internal/tenant/app/knowledge_service.go +++ b/server/internal/tenant/app/knowledge_service.go @@ -131,10 +131,12 @@ type KnowledgeURLItemRequest struct { } type KnowledgeContext struct { - GroupIDs []int64 - GroupNames []string - Snippets []KnowledgeSnippet - PreciseFacts []string + GroupIDs []int64 + GroupNames []string + Snippets []KnowledgeSnippet + PreciseFacts []string + FactConstraints []KnowledgeFactConstraint + FactConflicts []KnowledgeFactConflict } type KnowledgeSnippet struct { @@ -936,6 +938,7 @@ func (s *KnowledgeService) ResolveContext( Snippets: []KnowledgeSnippet{}, PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, nil), } + s.applyKnowledgeFactConstraints(baseContext) vectors, err := s.provider.Embed(ctx, queries) if err != nil { @@ -994,6 +997,7 @@ func (s *KnowledgeService) ResolveContext( selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected) + s.applyKnowledgeFactConstraints(baseContext) s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected) return baseContext, nil } @@ -1002,6 +1006,7 @@ func (s *KnowledgeService) ResolveContext( selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected) + s.applyKnowledgeFactConstraints(baseContext) s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected) return baseContext, nil } @@ -1348,7 +1353,7 @@ func normalizeKnowledgeSnippetTextKey(text string) 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 && len(ctx.FactConstraints) == 0) { return "" } @@ -1357,6 +1362,21 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string { if len(preciseFacts) == 0 { preciseFacts = collectKnowledgePromptPreciseFacts(ctx.Snippets) } + factConstraints := ctx.FactConstraints + if len(factConstraints) == 0 { + var conflicts []KnowledgeFactConflict + factConstraints, conflicts = buildKnowledgeFactConstraints(preciseFacts) + preciseFacts = removeConflictingKnowledgeFacts(preciseFacts, conflicts) + } + if len(factConstraints) > 0 { + lines = append(lines, + "以下为系统落库前会自动校验的规范事实;涉及对应主体时只能使用这些值,不得引用旧版本、推算年限或自行补充数字:", + ) + for _, constraint := range factConstraints { + lines = append(lines, "- "+renderKnowledgeFactConstraint(constraint)) + } + lines = append(lines, "") + } if len(preciseFacts) > 0 { lines = append(lines, "以下为必须严格按原文保留的高精度事实;如果正文需要引用,必须逐字一致,不得改写、翻译、推测或补全:", @@ -1379,6 +1399,27 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string { return strings.Join(lines, "\n") } +func (s *KnowledgeService) applyKnowledgeFactConstraints(ctx *KnowledgeContext) { + if ctx == nil { + return + } + constraints, conflicts := buildKnowledgeFactConstraints(ctx.PreciseFacts) + ctx.PreciseFacts = removeConflictingKnowledgeFacts(ctx.PreciseFacts, conflicts) + ctx.FactConstraints = constraints + ctx.FactConflicts = conflicts + if s == nil || s.logger == nil { + return + } + for _, conflict := range conflicts { + s.logger.Warn("knowledge fact source conflict", + zap.String("fact_kind", string(conflict.Kind)), + zap.String("fact_subject", conflict.Subject), + zap.String("canonical_value", conflict.CanonicalValue), + zap.String("conflicting_value", conflict.ConflictingValue), + ) + } +} + type knowledgePromptItemRecord struct { ID int64 GroupID int64 @@ -1601,11 +1642,11 @@ func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs( AND ki.deleted_at IS NULL AND ki.group_id IN (SELECT id FROM selected_groups) ORDER BY + ki.updated_at DESC, CASE WHEN ki.id = ANY($4::bigint[]) THEN 0 ELSE 1 END, - ki.updated_at DESC, ki.id DESC LIMIT $5 `, tenantID, brandID, groupIDs, prioritizedItemIDs, limit) @@ -1783,7 +1824,9 @@ func knowledgePreciseFactLabelOnly(line string) bool { "主营业务", "主要业务", "核心业务", "业务范围", "经营范围", "服务范围", "主营产品", "主要产品", "核心产品", "产品范围", "main business", "core business", "business scope", "service scope", - "main products", "core products", "product scope": + "main products", "core products", "product scope", + "成立时间", "创立时间", "创办时间", "创建时间", "注册时间", "注册成立时间", + "服务年限", "行业经验", "从业经验", "经营年限", "company founded", "founded", "established": return true default: return false @@ -1808,6 +1851,9 @@ func knowledgeLineContainsPreciseFact(line string) bool { "opening hours", "business hours", "postcode", "zip code", "main business", "core business", "business scope", "service scope", "main products", "core products", "product scope", + "成立于", "成立时间", "创立于", "创立时间", "创办于", "创办时间", "创建于", "创建时间", "注册成立", "注册时间", + "服务经验", "行业经验", "从业经验", "经营经验", "经营年限", "深耕", + "founded", "established", "years of experience", } for _, keyword := range keywords { if strings.Contains(lower, keyword) { @@ -1853,6 +1899,9 @@ func knowledgeLineStartsAnotherPreciseFact(line string) bool { "opening hours:", "business hours:", "main business:", "core business:", "business scope:", "service scope:", "main products:", "core products:", "product scope:", + "成立时间:", "创立时间:", "创办时间:", "创建时间:", "注册时间:", "注册成立时间:", + "服务年限:", "行业经验:", "从业经验:", "经营年限:", + "company founded:", "founded:", "established:", } for _, label := range labels { if strings.HasPrefix(lower, label) {