Files
geo/server/internal/tenant/app/knowledge_fact_guard.go
T
root 418b3c4998 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>
2026-07-10 20:21:59 +08:00

538 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
}