package chatpipeline import ( "context" "errors" "strings" "testing" "github.com/Tencent/WeKnora/internal/types" ) const graphRestrictionTestBlock = "〔 不适宜人群 〕 对甲类慎用 、 乙类 、 丙类" const graphRestrictionTestSource = "产品名称:甲品\n" + graphRestrictionTestBlock + "。" func TestGraphRestrictionStrengthBelongsToItsOwnItem(t *testing.T) { for _, tc := range []struct { name, target, label, evidence, wantErr string }{ {"local caution", "甲类", "慎用于", "对甲类慎用", ""}, {"local caution preserves source preposition", "对甲类", "慎用于", "对甲类慎用", ""}, {"local caution with full list evidence", "甲类", "慎用于", graphRestrictionTestBlock, ""}, {"first inherited non suitability", "乙类", "不适用于", graphRestrictionTestBlock, ""}, {"last inherited non suitability", "丙类", "不适用于", graphRestrictionTestBlock, ""}, {"neighbor caution cannot propagate", "乙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"later neighbor caution cannot propagate", "丙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"caution cannot strengthen to non suitability", "甲类", "不适用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"caution cannot strengthen to prohibition", "甲类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"non suitability cannot strengthen to prohibition", "乙类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"permission cannot replace restriction", "乙类", "适用于", graphRestrictionTestBlock, "restriction strength mismatch"}, {"association cannot replace restriction", "乙类", "关联", graphRestrictionTestBlock, "restriction strength mismatch"}, {"heading predicate cannot be cropped", "乙类", "不适用于", "乙类", "governing predicate"}, {"local predicate cannot be cropped", "甲类", "慎用于", "甲类", "governing predicate"}, {"whole source constraint", graphRestrictionTestBlock, "约束", graphRestrictionTestBlock, ""}, {"truncated constraint", "乙类", "约束", graphRestrictionTestBlock, "restriction strength mismatch"}, {"changed target spelling", "乙组", "不适用于", graphRestrictionTestBlock, "ambiguous restriction list"}, } { t.Run(tc.name, func(t *testing.T) { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence}}} err := validateGraphRelationEvidence(graph, graphRestrictionTestSource) if tc.wantErr == "" { if err != nil { t.Fatal(err) } } else if !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("error = %v, want review containing %q", err, tc.wantErr) } }) } } func TestGraphRestrictionScopesRetainSourceSpans(t *testing.T) { for _, tc := range []struct { block, target, label string }{ {"【慎用人群】甲类,乙类", "乙类", "慎用于"}, {"〖禁用对象〕甲类;乙类", "乙类", "禁用于"}, {"[不适用人群] 丙类、乙类", "丙类", "不适用于"}, {"不适宜人群:丙类;乙类", "丙类", "不适用于"}, {"〔 不适宜人群 〕 对甲类慎甩,乙丿 IJ 类、丙类", "乙丿 IJ 类", "不适用于"}, {"〔不适合对象〕甲类\n 、\t乙类", "乙类", "不适用于"}, {"〔慎用人群〕甲类禁用、乙类", "甲类", "禁用于"}, {"〔慎用人群〕甲类禁用、乙类", "乙类", "慎用于"}, } { t.Run(tc.block, func(t *testing.T) { source := "产品名称:甲品\n" + tc.block + "\n〔贮存条件〕阴凉处保存。" scopes := graphRestrictionScopes(source) if len(scopes) != 1 || source[scopes[0].start:scopes[0].end] != tc.block { t.Fatal("restriction list boundaries changed source text") } relation := &types.GraphRelation{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.block} if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil { t.Fatal(err) } if relation.Evidence != tc.block || relation.Node2 != tc.target { t.Fatal("validation mutated source text or target") } }) } } func TestGraphRestrictionAmbiguityRequiresCompleteLiteralBlock(t *testing.T) { for _, block := range []string{ "〔不适宜人群〕甲类、乙类慎用", "〔不适宜人群〕甲类、甲类", "〔不适宜人群〕甲类和乙类慎用", "〔不适宜人群〕甲类慎甪、乙类", "〔不适冝人群〕甲类、乙类", "〔不适宜人群〕甲类(特殊条件除外)、乙类", } { t.Run(block, func(t *testing.T) { source := "本品\n" + block + "。" graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "甲类", Type: "不适用于", Evidence: block}}} if err := validateGraphRelationEvidence(graph, source); !errors.Is(err, ErrGraphNeedsReview) { t.Fatalf("ambiguous list accepted: %v", err) } graph.Relation[0].Node2, graph.Relation[0].Type = block, "约束" if err := validateGraphRelationEvidence(graph, source); err != nil { t.Fatal("literal constraint rejected", err) } if err := validateGraphRestrictionCoverage(graph, source); err != nil { t.Fatal("complete literal constraint lost coverage", err) } graph.Relation[0].Node2 = strings.TrimPrefix(block, "〔") if err := validateGraphRestrictionCoverage(graph, source); !errors.Is(err, ErrGraphNeedsReview) { t.Fatal("rewritten target satisfied full-block coverage") } }) } } func TestGraphRestrictionCoverageCannotDropOrHideMembers(t *testing.T) { valid := []*types.GraphRelation{ {Node1: "甲品", Node2: "甲类", Type: "慎用于", Evidence: "对甲类慎用"}, {Node1: "甲品", Node2: "乙类", Type: "不适用于", Evidence: graphRestrictionTestBlock}, {Node1: "甲品", Node2: "丙类", Type: "不适用于", Evidence: graphRestrictionTestBlock}, } if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil { t.Fatal(err) } valid[0].Node2 = "对甲类" if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil { t.Fatal("literal source preposition lost coverage", err) } valid[0].Node2 = "甲类" for missing := range valid { relations := append([]*types.GraphRelation{}, valid[:missing]...) relations = append(relations, valid[missing+1:]...) graph := &types.GraphData{Node: []*types.GraphNode{{Name: "甲品", Attributes: []string{graphRestrictionTestBlock}}}, Relation: relations} if err := validateGraphRestrictionCoverage(graph, graphRestrictionTestSource); !errors.Is(err, ErrGraphNeedsReview) { t.Fatal("attributes or neighboring edges concealed a missing list member") } } const storage = "阴凉处保存" graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: storage, Type: "约束", Evidence: storage}}} response := graphCoverageResponse(t, graph) source := graphRestrictionTestSource + "\n" + storage + "。" if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil || !errors.Is(err, ErrGraphNeedsReview) { t.Fatal("unrelated valid relation allowed restriction omission") } } func TestGraphRestrictionEvidenceAndStrengthErrorsShareOneCorrection(t *testing.T) { badQuote := strings.Replace(graphRestrictionTestBlock, "〔", "(", 1) bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: badQuote}}} model := &extractionResponseChat{response: graphCoverageResponse(t, bad)} extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"}) graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource) if graph != nil || err == nil || model.calls != 2 || model.options.MaxTokens != 4096 || model.options.Temperature != 0 { t.Fatal("invalid response escaped bounded correction") } for _, message := range []string{err.Error(), model.messages[3].Content} { if !strings.Contains(message, "requires exact evidence") || !strings.Contains(message, "restriction strength mismatch") { t.Fatal("quote error hid independently anchored restriction strength") } } if bad.Relation[0].Evidence != badQuote || bad.Relation[0].Type != "慎用于" { t.Fatal("invalid model output was silently repaired") } if !strings.Contains(model.messages[0].Content, "A heading that governs a list supplies its predicate") || strings.Contains(model.messages[0].Content, "section titles need not be included") { t.Fatal("persisted prompt still allows predicate headings to disappear") } if !strings.Contains(model.messages[0].Content, "literal OCR spelling in restriction targets, entity names and attributes") || !strings.Contains(model.messages[0].Content, "Preserve supported usage procedures") || !strings.Contains(model.messages[3].Content, "限制实体名称和属性也必须保留原文 OCR 字形") || !strings.Contains(model.messages[3].Content, "保留有据的用量及使用步骤约束关系") { t.Fatal("correction omitted attribute grounding or usage preservation") } } func TestGraphRestrictionCorrectionMustExplicitlyPreserveCompleteSource(t *testing.T) { bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: strings.Replace(graphRestrictionTestBlock, "〔", "(", 1)}}} good := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: graphRestrictionTestBlock, Type: "约束", Evidence: graphRestrictionTestBlock}}} model := &extractionResponseChat{responses: []*types.ChatResponse{graphCoverageResponse(t, bad), graphCoverageResponse(t, good)}} extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"}) graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource) if err != nil || graph == nil || model.calls != 2 || len(graph.Relation) != 1 || graph.Relation[0].Type != "约束" || graph.Relation[0].Node2 != graphRestrictionTestBlock || graph.Relation[0].Evidence != graphRestrictionTestBlock { t.Fatal("explicit complete source constraint was not preserved by bounded correction") } good.Relation = append(good.Relation, &types.GraphRelation{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: graphRestrictionTestBlock}) response := graphCoverageResponse(t, good) source := graphRestrictionTestSource if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "restriction strength mismatch") { t.Fatal("full constraint concealed a contradictory typed edge") } } func TestGraphRestrictionQuotationMutationsStayRejected(t *testing.T) { const block = "〖 不适宜人群 〕 甲丿 IJ 类\n 、\t乙类" source := "本品\n" + block + "。" for _, quote := range []string{ strings.Replace(block, "〖", "〔", 1), strings.Replace(block, "甲丿 IJ 类", "甲儿童类", 1), strings.ReplaceAll(block, " ", ""), strings.ReplaceAll(block, "\n", `\n`), strings.ReplaceAll(block, "\t", " "), } { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "乙类", Type: "不适用于", Evidence: quote}}} if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "requires exact evidence") { t.Fatal("mutated source quotation accepted") } } } func TestGraphRestrictionScopeCannotCrossProducts(t *testing.T) { const first = "〔不适宜人群〕甲类、乙类" const second = "〔慎用人群〕甲类、乙类" source := "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n" + second + "。" for _, tc := range []struct { subject, quote, label string wantErr bool }{ {"甲品", first, "不适用于", false}, {"乙品", second, "慎用于", false}, {"甲品", second, "慎用于", true}, {"乙品", first, "不适用于", true}, {"本品", "甲类", "慎用于", true}, {"未出现产品", first, "不适用于", true}, {"乙类", first, "不适用于", true}, } { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "甲类", Type: tc.label, Evidence: tc.quote}}} err := validateGraphRelationEvidence(graph, source) if (err != nil) != tc.wantErr { t.Fatalf("subject=%s, label=%s, error=%v", tc.subject, tc.label, err) } } } func TestGraphRestrictionListsDoNotChangeQueryOrNarrativeExtraction(t *testing.T) { const source = "小说章节〔不适宜人群〕甲类、乙类。" if len(graphRestrictionScopes(source)) != 0 { t.Fatal("non-product narrative became a product restriction list") } model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"甲品"}]`, FinishReason: "stop"}} extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"}) graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource) if err != nil || graph == nil || model.calls != 1 { t.Fatal("query extraction acquired persisted restriction coverage") } }