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:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user