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] }