418b3c4998
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>
538 lines
16 KiB
Go
538 lines
16 KiB
Go
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]
|
||
}
|