Files
geo/server/internal/tenant/app/knowledge_fact_guard.go
T

538 lines
16 KiB
Go
Raw Normal View History

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