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:
@@ -317,9 +317,10 @@ runtime:
|
||||
avoid_points: "需要规避"
|
||||
extra_requirements: "其他要求"
|
||||
knowledge_prompt_intro_lines:
|
||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
|
||||
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
|
||||
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
|
||||
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
|
||||
- "- 信息不足时不要虚构事实。"
|
||||
knowledge_snippet_label_format: "知识片段 %d"
|
||||
knowledge_website_markdown:
|
||||
|
||||
+4
-3
@@ -317,9 +317,10 @@ runtime:
|
||||
avoid_points: "需要规避"
|
||||
extra_requirements: "其他要求"
|
||||
knowledge_prompt_intro_lines:
|
||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
|
||||
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
|
||||
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
|
||||
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
|
||||
- "- 信息不足时不要虚构事实。"
|
||||
knowledge_snippet_label_format: "知识片段 %d"
|
||||
knowledge_website_markdown:
|
||||
|
||||
@@ -317,9 +317,10 @@ runtime:
|
||||
avoid_points: "需要规避"
|
||||
extra_requirements: "其他要求"
|
||||
knowledge_prompt_intro_lines:
|
||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||
- "- 若知识库内容与用户明确输入冲突,以用户输入为准。"
|
||||
- "以下知识库内容是事实来源,可用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 地址、电话、官网、邮箱、营业时间、成立/注册日期、年份、数量、面积、人数、比例、价格、认证编号,以及主营业务、经营范围、服务范围、核心产品等业务事实,只能严格依据已提供原文,不得改写数值、混用旧值、推测或补全。"
|
||||
- "- 不得根据成立年份自行推算服务年限、行业经验或其他衍生数字;知识库未明确提供的数字型业务事实不要写。"
|
||||
- "- 若临时输入与下方系统标记的规范事实冲突,以规范事实为准;需要更正时应先更新知识库。"
|
||||
- "- 信息不足时不要虚构事实。"
|
||||
knowledge_snippet_label_format: "知识片段 %d"
|
||||
knowledge_website_markdown:
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
)
|
||||
|
||||
func TestExtractKnowledgePreciseFactsKeepsFoundingDateAndExplicitExperience(t *testing.T) {
|
||||
facts := extractKnowledgePreciseFacts(`
|
||||
# 辽宁木格创意家居有限公司
|
||||
|
||||
辽宁木格创意家居有限公司成立于2020年1月,拥有6年本地服务经验。
|
||||
普通介绍段落。
|
||||
`)
|
||||
|
||||
want := "辽宁木格创意家居有限公司成立于2020年1月,拥有6年本地服务经验。"
|
||||
if !containsExactString(facts, want) {
|
||||
t.Fatalf("extractKnowledgePreciseFacts() = %#v, want %q", facts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKnowledgeFactConstraintsUsesNewestFactAndReportsOlderConflict(t *testing.T) {
|
||||
facts := []string{
|
||||
"默认目录 / 最新简介:辽宁木格创意家居有限公司成立于2020年1月,深耕定制家居领域。",
|
||||
"默认目录 / 旧版简介:辽宁木格创意家居有限公司成立于2019年,目前是一家现代化企业。",
|
||||
}
|
||||
|
||||
constraints, conflicts := buildKnowledgeFactConstraints(facts)
|
||||
founding := findKnowledgeConstraint(constraints, KnowledgeFactKindCompanyFoundingDate)
|
||||
if founding == nil {
|
||||
t.Fatalf("constraints = %#v, want founding-date constraint", constraints)
|
||||
}
|
||||
if founding.Value != "2020-01" {
|
||||
t.Fatalf("founding value = %q, want 2020-01", founding.Value)
|
||||
}
|
||||
if founding.Subject != "辽宁木格创意家居有限公司" {
|
||||
t.Fatalf("founding subject = %q, want company name", founding.Subject)
|
||||
}
|
||||
if len(conflicts) != 1 || conflicts[0].CanonicalValue != "2020-01" || conflicts[0].ConflictingValue != "2019" {
|
||||
t.Fatalf("conflicts = %#v, want one 2020-01 vs 2019 conflict", conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPromptSectionDropsOlderConflictingFoundingDate(t *testing.T) {
|
||||
output := (&KnowledgeService{}).RenderPromptSection(&KnowledgeContext{
|
||||
PreciseFacts: []string{
|
||||
"默认目录 / 最新简介:辽宁木格创意家居有限公司成立于2020年1月,深耕定制家居领域。",
|
||||
"默认目录 / 旧版简介:辽宁木格创意家居有限公司成立于2019年,目前是一家现代化企业。",
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "辽宁木格创意家居有限公司:成立时间=2020年1月") {
|
||||
t.Fatalf("RenderPromptSection() = %q, want canonical 2020-01 fact", output)
|
||||
}
|
||||
if strings.Contains(output, "成立于2019年") {
|
||||
t.Fatalf("RenderPromptSection() = %q, must not expose conflicting 2019 fact", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeneratedKnowledgeFactsRejectsReportedFoundingContradiction(t *testing.T) {
|
||||
constraints := mugeKnowledgeConstraints()
|
||||
content := `
|
||||
## 1. 第一名:辽宁木格创意家居有限公司|评分9.8分
|
||||
|
||||
- 公司介绍:品牌成立于2014年,拥有12年本地服务经验,自有6000㎡生产基地。
|
||||
`
|
||||
|
||||
violations := ValidateGeneratedKnowledgeFacts(content, constraints)
|
||||
assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyFoundingDate, "2020-01", "2014")
|
||||
assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyExperienceYears, "知识库未提供该年限", "12")
|
||||
}
|
||||
|
||||
func TestValidateGeneratedKnowledgeFactsHandlesPercentEncodedMarkdown(t *testing.T) {
|
||||
content := `## 第一名:辽宁木格创意家居有限公司
|
||||
品牌成立于2014年,拥有12年本地服务经验。`
|
||||
|
||||
violations := ValidateGeneratedKnowledgeFacts(url.PathEscape(content), mugeKnowledgeConstraints())
|
||||
assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyFoundingDate, "2020-01", "2014")
|
||||
assertKnowledgeViolation(t, violations, KnowledgeFactKindCompanyExperienceYears, "知识库未提供该年限", "12")
|
||||
}
|
||||
|
||||
func TestValidateGeneratedKnowledgeFactsAcceptsCanonicalDateAndIgnoresOtherSubjects(t *testing.T) {
|
||||
content := `
|
||||
## 第一名:辽宁木格创意家居有限公司
|
||||
辽宁木格创意家居有限公司成立于2020年1月。
|
||||
|
||||
## 第二名:葫芦岛示例家居有限公司
|
||||
该公司成立于2014年。
|
||||
`
|
||||
|
||||
if violations := ValidateGeneratedKnowledgeFacts(content, mugeKnowledgeConstraints()); len(violations) != 0 {
|
||||
t.Fatalf("ValidateGeneratedKnowledgeFacts() = %#v, want no violations", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateArticleWithKnowledgeFactGuardRepairsOnceBeforeReturning(t *testing.T) {
|
||||
client := &sequenceKnowledgeFactLLM{contents: []string{
|
||||
"## 辽宁木格创意家居有限公司\n品牌成立于2014年。",
|
||||
"## 辽宁木格创意家居有限公司\n品牌成立于2020年1月。",
|
||||
}}
|
||||
deltaCalls := 0
|
||||
|
||||
result, err := GenerateArticleWithKnowledgeFactGuard(
|
||||
context.Background(),
|
||||
client,
|
||||
llm.GenerateRequest{Prompt: "生成品牌文章", Model: "test-model"},
|
||||
mugeKnowledgeConstraints(),
|
||||
func(string) { deltaCalls++ },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateArticleWithKnowledgeFactGuard() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "2020年1月") {
|
||||
t.Fatalf("result content = %q, want repaired founding date", result.Content)
|
||||
}
|
||||
if len(client.requests) != 2 {
|
||||
t.Fatalf("Generate calls = %d, want 2", len(client.requests))
|
||||
}
|
||||
if deltaCalls != 0 || client.nonNilCallbacks != 0 {
|
||||
t.Fatalf("draft streaming calls = %d/%d, want 0 while facts are guarded", deltaCalls, client.nonNilCallbacks)
|
||||
}
|
||||
if !strings.Contains(client.requests[1].Prompt, "2020年1月") || !strings.Contains(client.requests[1].Prompt, "2014年") {
|
||||
t.Fatalf("repair prompt = %q, want canonical fact and invalid draft", client.requests[1].Prompt)
|
||||
}
|
||||
if !strings.Contains(client.requests[1].Prompt, "品牌资料/知识库规范事实") {
|
||||
t.Fatalf("repair prompt = %q, want brand and knowledge fact authority", client.requests[1].Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateArticleWithKnowledgeFactGuardFailsAfterSecondConflict(t *testing.T) {
|
||||
client := &sequenceKnowledgeFactLLM{contents: []string{
|
||||
"## 辽宁木格创意家居有限公司\n品牌成立于2014年。",
|
||||
"## 辽宁木格创意家居有限公司\n品牌成立于2018年。",
|
||||
}}
|
||||
|
||||
_, err := GenerateArticleWithKnowledgeFactGuard(
|
||||
context.Background(),
|
||||
client,
|
||||
llm.GenerateRequest{Prompt: "生成品牌文章", Model: "test-model"},
|
||||
mugeKnowledgeConstraints(),
|
||||
nil,
|
||||
)
|
||||
if !errors.Is(err, ErrKnowledgeFactConflict) {
|
||||
t.Fatalf("error = %v, want ErrKnowledgeFactConflict", err)
|
||||
}
|
||||
if len(client.requests) != 2 {
|
||||
t.Fatalf("Generate calls = %d, want 2", len(client.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeFactConstraintSnapshotRoundTrip(t *testing.T) {
|
||||
payload := attachKnowledgeFactConstraints(map[string]interface{}{}, mugeKnowledgeConstraints())
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if got := extractKnowledgeFactConstraints(decoded); !reflect.DeepEqual(got, mugeKnowledgeConstraints()) {
|
||||
t.Fatalf("constraint snapshot = %#v, want %#v", got, mugeKnowledgeConstraints())
|
||||
}
|
||||
}
|
||||
|
||||
func mugeKnowledgeConstraints() []KnowledgeFactConstraint {
|
||||
return []KnowledgeFactConstraint{
|
||||
{
|
||||
Kind: KnowledgeFactKindCompanyFoundingDate,
|
||||
Subject: "辽宁木格创意家居有限公司",
|
||||
Aliases: []string{"辽宁木格创意家居有限公司", "木格创意家居"},
|
||||
Value: "2020-01",
|
||||
SourceText: "辽宁木格创意家居有限公司成立于2020年1月",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findKnowledgeConstraint(constraints []KnowledgeFactConstraint, kind KnowledgeFactKind) *KnowledgeFactConstraint {
|
||||
for index := range constraints {
|
||||
if constraints[index].Kind == kind {
|
||||
return &constraints[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertKnowledgeViolation(
|
||||
t *testing.T,
|
||||
violations []KnowledgeFactViolation,
|
||||
kind KnowledgeFactKind,
|
||||
expected string,
|
||||
actual string,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, violation := range violations {
|
||||
if violation.Kind == kind && violation.Expected == expected && violation.Actual == actual {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("violations = %#v, want kind=%q expected=%q actual=%q", violations, kind, expected, actual)
|
||||
}
|
||||
|
||||
type sequenceKnowledgeFactLLM struct {
|
||||
contents []string
|
||||
requests []llm.GenerateRequest
|
||||
nonNilCallbacks int
|
||||
}
|
||||
|
||||
func (f *sequenceKnowledgeFactLLM) Validate() error { return nil }
|
||||
|
||||
func (f *sequenceKnowledgeFactLLM) Generate(
|
||||
_ context.Context,
|
||||
req llm.GenerateRequest,
|
||||
onDelta func(string),
|
||||
) (*llm.GenerateResult, error) {
|
||||
f.requests = append(f.requests, req)
|
||||
if onDelta != nil {
|
||||
f.nonNilCallbacks++
|
||||
onDelta("draft")
|
||||
}
|
||||
index := len(f.requests) - 1
|
||||
if index >= len(f.contents) {
|
||||
return nil, errors.New("unexpected generate call")
|
||||
}
|
||||
return &llm.GenerateResult{Content: f.contents[index], Model: req.Model}, nil
|
||||
}
|
||||
@@ -131,10 +131,12 @@ type KnowledgeURLItemRequest struct {
|
||||
}
|
||||
|
||||
type KnowledgeContext struct {
|
||||
GroupIDs []int64
|
||||
GroupNames []string
|
||||
Snippets []KnowledgeSnippet
|
||||
PreciseFacts []string
|
||||
GroupIDs []int64
|
||||
GroupNames []string
|
||||
Snippets []KnowledgeSnippet
|
||||
PreciseFacts []string
|
||||
FactConstraints []KnowledgeFactConstraint
|
||||
FactConflicts []KnowledgeFactConflict
|
||||
}
|
||||
|
||||
type KnowledgeSnippet struct {
|
||||
@@ -936,6 +938,7 @@ func (s *KnowledgeService) ResolveContext(
|
||||
Snippets: []KnowledgeSnippet{},
|
||||
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, nil),
|
||||
}
|
||||
s.applyKnowledgeFactConstraints(baseContext)
|
||||
|
||||
vectors, err := s.provider.Embed(ctx, queries)
|
||||
if err != nil {
|
||||
@@ -994,6 +997,7 @@ func (s *KnowledgeService) ResolveContext(
|
||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected)
|
||||
baseContext.Snippets = selected
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected)
|
||||
s.applyKnowledgeFactConstraints(baseContext)
|
||||
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected)
|
||||
return baseContext, nil
|
||||
}
|
||||
@@ -1002,6 +1006,7 @@ func (s *KnowledgeService) ResolveContext(
|
||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, brandID, selected)
|
||||
baseContext.Snippets = selected
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, brandID, searchGroupIDs, selected)
|
||||
s.applyKnowledgeFactConstraints(baseContext)
|
||||
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected)
|
||||
return baseContext, nil
|
||||
}
|
||||
@@ -1348,7 +1353,7 @@ func normalizeKnowledgeSnippetTextKey(text string) string {
|
||||
}
|
||||
|
||||
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0) {
|
||||
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0 && len(ctx.FactConstraints) == 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1357,6 +1362,21 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||
if len(preciseFacts) == 0 {
|
||||
preciseFacts = collectKnowledgePromptPreciseFacts(ctx.Snippets)
|
||||
}
|
||||
factConstraints := ctx.FactConstraints
|
||||
if len(factConstraints) == 0 {
|
||||
var conflicts []KnowledgeFactConflict
|
||||
factConstraints, conflicts = buildKnowledgeFactConstraints(preciseFacts)
|
||||
preciseFacts = removeConflictingKnowledgeFacts(preciseFacts, conflicts)
|
||||
}
|
||||
if len(factConstraints) > 0 {
|
||||
lines = append(lines,
|
||||
"以下为系统落库前会自动校验的规范事实;涉及对应主体时只能使用这些值,不得引用旧版本、推算年限或自行补充数字:",
|
||||
)
|
||||
for _, constraint := range factConstraints {
|
||||
lines = append(lines, "- "+renderKnowledgeFactConstraint(constraint))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if len(preciseFacts) > 0 {
|
||||
lines = append(lines,
|
||||
"以下为必须严格按原文保留的高精度事实;如果正文需要引用,必须逐字一致,不得改写、翻译、推测或补全:",
|
||||
@@ -1379,6 +1399,27 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s *KnowledgeService) applyKnowledgeFactConstraints(ctx *KnowledgeContext) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
constraints, conflicts := buildKnowledgeFactConstraints(ctx.PreciseFacts)
|
||||
ctx.PreciseFacts = removeConflictingKnowledgeFacts(ctx.PreciseFacts, conflicts)
|
||||
ctx.FactConstraints = constraints
|
||||
ctx.FactConflicts = conflicts
|
||||
if s == nil || s.logger == nil {
|
||||
return
|
||||
}
|
||||
for _, conflict := range conflicts {
|
||||
s.logger.Warn("knowledge fact source conflict",
|
||||
zap.String("fact_kind", string(conflict.Kind)),
|
||||
zap.String("fact_subject", conflict.Subject),
|
||||
zap.String("canonical_value", conflict.CanonicalValue),
|
||||
zap.String("conflicting_value", conflict.ConflictingValue),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type knowledgePromptItemRecord struct {
|
||||
ID int64
|
||||
GroupID int64
|
||||
@@ -1601,11 +1642,11 @@ func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs(
|
||||
AND ki.deleted_at IS NULL
|
||||
AND ki.group_id IN (SELECT id FROM selected_groups)
|
||||
ORDER BY
|
||||
ki.updated_at DESC,
|
||||
CASE
|
||||
WHEN ki.id = ANY($4::bigint[]) THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
ki.updated_at DESC,
|
||||
ki.id DESC
|
||||
LIMIT $5
|
||||
`, tenantID, brandID, groupIDs, prioritizedItemIDs, limit)
|
||||
@@ -1783,7 +1824,9 @@ func knowledgePreciseFactLabelOnly(line string) bool {
|
||||
"主营业务", "主要业务", "核心业务", "业务范围", "经营范围", "服务范围",
|
||||
"主营产品", "主要产品", "核心产品", "产品范围",
|
||||
"main business", "core business", "business scope", "service scope",
|
||||
"main products", "core products", "product scope":
|
||||
"main products", "core products", "product scope",
|
||||
"成立时间", "创立时间", "创办时间", "创建时间", "注册时间", "注册成立时间",
|
||||
"服务年限", "行业经验", "从业经验", "经营年限", "company founded", "founded", "established":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1808,6 +1851,9 @@ func knowledgeLineContainsPreciseFact(line string) bool {
|
||||
"opening hours", "business hours", "postcode", "zip code",
|
||||
"main business", "core business", "business scope", "service scope",
|
||||
"main products", "core products", "product scope",
|
||||
"成立于", "成立时间", "创立于", "创立时间", "创办于", "创办时间", "创建于", "创建时间", "注册成立", "注册时间",
|
||||
"服务经验", "行业经验", "从业经验", "经营经验", "经营年限", "深耕",
|
||||
"founded", "established", "years of experience",
|
||||
}
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
@@ -1853,6 +1899,9 @@ func knowledgeLineStartsAnotherPreciseFact(line string) bool {
|
||||
"opening hours:", "business hours:",
|
||||
"main business:", "core business:", "business scope:", "service scope:",
|
||||
"main products:", "core products:", "product scope:",
|
||||
"成立时间:", "创立时间:", "创办时间:", "创建时间:", "注册时间:", "注册成立时间:",
|
||||
"服务年限:", "行业经验:", "从业经验:", "经营年限:",
|
||||
"company founded:", "founded:", "established:",
|
||||
}
|
||||
for _, label := range labels {
|
||||
if strings.HasPrefix(lower, label) {
|
||||
|
||||
Reference in New Issue
Block a user