feat(tenant): guard numeric knowledge facts against silent contradiction

Ensure numeric facts present in retrieved knowledge (especially company
founding dates) cannot be silently contradicted in generated articles.

- Add a typed fact guard that extracts constraints (dates, experience
  years, etc.) from precise facts, detects source conflicts, and
  validates generated content before persistence with a bounded repair
  retry.
- Surface fact constraints through KnowledgeContext and render them as
  a canonical, non-derivable fact block in the knowledge prompt section.
- Tighten prompt intro lines to forbid rewriting, mixing old values,
  or deriving service years from founding dates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:10:28 +08:00
parent 5463319b40
commit 418b3c4998
6 changed files with 839 additions and 16 deletions
+4 -3
View File
@@ -317,9 +317,10 @@ runtime:
avoid_points: "需要规避"
extra_requirements: "其他要求"
knowledge_prompt_intro_lines:
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
- "- 信息不足时不要虚构事实。"
knowledge_snippet_label_format: "知识片段 %d"
knowledge_website_markdown:
+4 -3
View File
@@ -317,9 +317,10 @@ runtime:
avoid_points: "需要规避"
extra_requirements: "其他要求"
knowledge_prompt_intro_lines:
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
- "- 信息不足时不要虚构事实。"
knowledge_snippet_label_format: "知识片段 %d"
knowledge_website_markdown:
+4 -3
View File
@@ -317,9 +317,10 @@ runtime:
avoid_points: "需要规避"
extra_requirements: "其他要求"
knowledge_prompt_intro_lines:
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
- "- 信息不足时不要虚构事实。"
knowledge_snippet_label_format: "知识片段 %d"
knowledge_website_markdown:
@@ -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]
}
@@ -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
}
@@ -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) {