package chatpipeline import ( "context" "encoding/json" "errors" "strings" "testing" "github.com/Tencent/WeKnora/internal/types" ) func TestGraphComparisonPreservesSourceAndOrdinaryNegation(t *testing.T) { for _, tc := range []struct{ source, comparison string }{ {"不不建议使用", "不建议使用"}, {"不能使用", "不能使用"}, {"不不能使用", "不不能使用"}, {"不不建议使用", "不不建议使用"}, {"开塞露露不不能⻓长期用", "开塞露露不能⻓长期用"}, } { if got := graphComparisonText(tc.source); got != tc.comparison { t.Errorf("comparison of %q = %q, want %q", tc.source, got, tc.comparison) } } } func TestGraphProductSectionHeadingCannotOwnStorageRule(t *testing.T) { const source = "本品不是药品。〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存 。" for _, tc := range []struct { name, subject, evidence string wantError bool }{ {"section without quoted heading", "储存方法", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true}, {"section included in evidence", "储存方法", "〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true}, {"local product owns rule", "本品", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", false}, } { t.Run(tc.name, func(t *testing.T) { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "储存于阴凉干燥处,避光、隔热、密封保存", Type: "约束", Evidence: tc.evidence}}} err := validateGraphRelationEvidenceItem(graph, source) if tc.wantError { if err == nil || !strings.Contains(err.Error(), "product section heading") { t.Fatalf("section incorrectly owns product rule: %v", err) } } else if err != nil { t.Fatal(err) } }) } graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "储存方法", Node2: "温度与湿度要求", Type: "包含", Evidence: "储存方法包含温度与湿度要求"}}} if err := validateGraphRelationEvidenceItem(graph, "教材的储存方法包含温度与湿度要求。"); err != nil { t.Fatalf("ordinary named concept rejected: %v", err) } for _, evidence := range []string{"注意事项要求记录审核日期", "〔 注意事项 〗 注意事项要求记录审核日期"} { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "注意事项", Node2: "记录审核日期", Type: "约束", Evidence: evidence}}} if err := validateGraphRelationEvidenceItem(graph, "〔 注意事项 〗 注意事项要求记录审核日期。"); err != nil { t.Fatalf("explicit body subject rejected: %v", err) } } } func TestGraphGenericAndCombinedUseEvidence(t *testing.T) { for _, tc := range []struct { name, source, from, to, label, quote, wantErr string }{ { name: "OCR prohibition preserves original evidence", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不不能用于破损皮肤", }, { name: "OCR negative advice remains recognized", source: "不不建议使用番泻叶。", from: "番泻叶", to: "使用", label: "不适用于", quote: "不不建议使用番泻叶", }, { name: "OCR prohibition cannot become permission", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "适用于", quote: "用于破损皮肤", wantErr: "conflicts with a source restriction", }, { name: "OCR comparison never repairs quotation", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不能用于破损皮肤", wantErr: "requires exact evidence", }, { name: "avoid combined use", source: "避免与拮抗药物同用。", from: "本品", to: "与拮抗药物同用", label: "禁用于", quote: "避免与拮抗药物同用", }, { name: "avoid combined use with alternate conjunction", source: "避免和其他产品合用。", from: "本品", to: "避免和其他产品合用", label: "约束", quote: "避免和其他产品合用", }, { name: "avoid simultaneous use", source: "避免与其他产品同时使用。", from: "本品", to: "与其他产品同时使用", label: "不适用于", quote: "避免与其他产品同时使用", }, { name: "combined operation cannot disappear", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物", label: "禁用于", quote: "避免与拮抗药物同用", wantErr: "omits the combined-use scope", }, { name: "avoid combined use cannot become permission", source: "避免和其他产品同时使用。", from: "本品", to: "和其他产品同时使用", label: "适用于", quote: "避免和其他产品同时使用", wantErr: "conflicts with a source restriction", }, { name: "avoid benefit is not a use prohibition", source: "本品可避免皮肤不适。", from: "本品", to: "皮肤不适", label: "禁用于", quote: "本品可避免皮肤不适", wantErr: "negative use label without a source restriction", }, { name: "identity cannot borrow effect denial", source: "本品不是药品,不具有治疗、护理、保健作用。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品,不具有治疗、护理、保健作用", wantErr: "identity disclaimer", }, { name: "identity alone does not restrict use", source: "本品不是药品。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品", wantErr: "identity disclaimer", }, { name: "effect disclaimer remains supported", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "本品不具有治疗作用", wantErr: "requires exact evidence", }, { name: "separate effect denial remains valid", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "不具有治疗作用", }, { name: "state prediction is not a use restriction", source: "第一次会议不会出现投票。", from: "第一次会议", to: "投票", label: "不适用于", quote: "第一次会议不会出现投票", wantErr: "negative use label without a source restriction", }, { name: "chapter heading is not the actor", source: "## 沟通指南\n\n明确目标支持积极建议。", from: "沟通指南", to: "积极建议", label: "支持", quote: "明确目标支持积极建议", wantErr: "source heading", }, { name: "actual named book can support practice", source: "## 沟通指南\n\n沟通指南支持沟通练习。", from: "沟通指南", to: "沟通练习", label: "支持", quote: "沟通指南支持沟通练习", }, { name: "uncertainty cannot be omitted", source: "可能是降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降", wantErr: "omits source attribution, time or uncertainty", }, { name: "uncertainty preserved in scoped association", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "关联", quote: "降价可能导致利润下降", }, { name: "uncertainty in evidence alone cannot make causal edge certain", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价可能导致利润下降", wantErr: "turns source possibility", }, { name: "uncertainty scoped in causal target", source: "降价可能导致利润下降。", from: "降价", to: "可能的利润下降", label: "导致", quote: "降价可能导致利润下降", }, { name: "explicit certainty does not need uncertainty scope", source: "降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降", }, { name: "historical view cannot become general truth", source: "以前我认为,工作支持自我肯定。", from: "工作", to: "自我肯定", label: "支持", quote: "以前我认为,工作支持自我肯定", wantErr: "attributed opinion", }, { name: "historical view remains attributed", source: "以前我认为,工作支持自我肯定。", from: "叙述者以前的观点", to: "工作支持自我肯定", label: "关联", quote: "以前我认为,工作支持自我肯定", }, } { t.Run(tc.name, func(t *testing.T) { raw, err := json.Marshal([]map[string]string{{"entity1": tc.from, "entity2": tc.to, "relation": tc.label, "evidence": tc.quote}}) if err != nil { t.Fatal(err) } model := &extractionResponseChat{response: &types.ChatResponse{Content: string(raw), FinishReason: "stop"}} extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"}) graph, err := extractor.Extract(context.Background(), tc.source) if tc.wantErr != "" { if graph != nil || err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("graph=%+v, err=%v, want %q", graph, err, tc.wantErr) } return } if err != nil || graph == nil || len(graph.Relation) != 1 || graph.Relation[0].Evidence != tc.quote { t.Fatalf("supported source quotation was rejected or changed: %+v, %v", graph, err) } }) } } func TestGraphEmptyCoverageRequiresReview(t *testing.T) { for _, response := range []string{"[]", `[{"entity":"标题","entity_attributes":["没有关系"]}]`} { model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}} extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"}) graph, err := extractor.Extract(context.Background(), "标题,没有关系。") if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "graph_needs_review") || model.calls != 2 { t.Fatalf("zero-edge coverage must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls) } } } func TestGraphCoverageCorrectionCanRestoreSupportedRelationship(t *testing.T) { const source = "基础概念是实践练习的先修条件。" model := &extractionResponseChat{responses: []*types.ChatResponse{ {Content: `[{"entity":"基础概念"},{"entity":"实践练习"}]`, FinishReason: "stop"}, {Content: `[{"entity1":"基础概念","entity2":"实践练习","relation":"先修条件","evidence":"基础概念是实践练习的先修条件。"}]`, FinishReason: "stop"}, }} extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"}) graph, err := extractor.Extract(context.Background(), source) if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 { t.Fatalf("supported correction failed: %+v, %v, calls=%d", graph, err, model.calls) } for _, instruction := range []string{"actual subject", "Product-use rules", "speaker, time and uncertainty", "不是药品", "Empty or entity-only results require coverage review"} { if !strings.Contains(model.messages[0].Content, instruction) { t.Errorf("graph prompt lacks %q", instruction) } } } func TestGraphProductBoundaryAndConstraintRegressions(t *testing.T) { for _, tc := range []struct { name, source, from, to, label, quote, wantErr string }{ {name: "OCR spaced numbered product", source: "本品不应用于疾病预防。\n\n02 丨 NMN 寻幽紧爱修护套 ( 紧致凝胶 )\n产品成分:甲。", from: "NMN寻幽紧爱修护套(紧致凝胶)", to: "疾病预防", label: "不适用于", quote: "本品不应用于疾病预防", wantErr: "earlier unnamed product"}, {name: "brand and name in separate table columns", source: "不得用于疾病预防。\n\n## 工作表1\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "疾病预防", label: "不适用于", quote: "不得用于疾病预防", wantErr: "earlier unnamed product"}, {name: "same table row attribution retained", source: "不得用于疾病预防。\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "保湿", label: "关联", quote: "| 玉丹 | 精华油 | 保湿 |"}, {name: "constraint retains prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用", wantErr: "omits the negation"}, {name: "constraint preserves prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "避免与拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用"}, {name: "stop action cannot disappear", source: "使用过程中,如有不适,请立即停止使用。", from: "使用过程中如有不适", to: "使用", label: "约束", quote: "使用过程中,如有不适,请立即停止使用", wantErr: "omits the stop-use action"}, {name: "stop condition cannot disappear", source: "如有不适,请立即停止使用。", from: "本品", to: "立即停止使用", label: "约束", quote: "如有不适,请立即停止使用", wantErr: "omits the stop-use condition"}, {name: "storage is not prohibition", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "禁用于", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "misclassifies a storage constraint"}, {name: "storage negation cannot disappear", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "omits the storage action"}, {name: "storage action retained", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阴凉干燥处存放并避免阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒"}, {name: "storage metadata is not the requirement", source: "贮存方法:避光密封保存。", from: "本品", to: "贮存条件", label: "约束", quote: "贮存方法:避光密封保存", wantErr: "omits the storage action"}, {name: "child access restriction cannot disappear", source: "请放置于儿童不易触及之处。", from: "本品", to: "儿童触及", label: "约束", quote: "请放置于儿童不易触及之处", wantErr: "storage action"}, } { t.Run(tc.name, func(t *testing.T) { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}} err := validateGraphRelationEvidence(graph, tc.source) if tc.wantErr == "" && err != nil { t.Fatal(err) } if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { t.Fatalf("got %v, want %q", err, tc.wantErr) } }) } } func TestGraphProductPronounsCannotMergeAcrossSections(t *testing.T) { const source = "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〕白番茄饮品\n〔 贮存方法 〕请置于阴凉处保存。" graph := &types.GraphData{Relation: []*types.GraphRelation{ {Node1: "本品", Node2: "阴凉干燥处存放", Type: "约束", Evidence: "请将本品置于阴凉干燥处存放"}, {Node1: "本品", Node2: "阴凉处保存", Type: "约束", Evidence: "请置于阴凉处保存"}, }} if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "merges an unnamed product") { t.Fatalf("ambiguous merged referent accepted: %v", err) } graph.Relation[1].Node1 = "白番茄饮品" if err := validateGraphRelationEvidence(graph, source); err != nil { t.Fatalf("distinct products should pass: %v", err) } } func TestGraphCorrectionReceivesMultipleFailures(t *testing.T) { graph := &types.GraphData{Relation: []*types.GraphRelation{ {Node1: "本品", Node2: "其他产品合用", Type: "约束", Evidence: "避免与其他产品合用"}, {Node1: "本品", Node2: "使用", Type: "约束", Evidence: "如有不适立即停止使用"}, }} err := validateGraphRelationEvidence(graph, "避免与其他产品合用。如有不适立即停止使用。") if err == nil || !strings.Contains(err.Error(), "omits the negation") || !strings.Contains(err.Error(), "omits the stop-use action") { t.Fatalf("correction must expose both failures: %v", err) } } func TestGraphExplicitRelationDirections(t *testing.T) { for _, tc := range []struct { name, from, to, label, quote string reversed bool }{ {"part cannot contain whole", "交流", "会议", "包含", "交流是会议的重要内容", true}, {"whole contains part", "会议", "交流", "包含", "交流是会议的重要内容", false}, {"literal whole and part reversed", "交流", "会议", "包含", "会议包含交流", true}, {"literal whole and part retained", "会议", "交流", "包含", "会议包含交流", false}, {"goal cannot precede prerequisite", "实践练习", "基础概念", "先修条件", "基础概念是实践练习的先修条件", true}, {"prerequisite precedes goal", "基础概念", "实践练习", "先修条件", "基础概念是实践练习的先修条件", false}, } { t.Run(tc.name, func(t *testing.T) { graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}} err := validateGraphRelationEvidence(graph, tc.quote) if tc.reversed && (err == nil || !strings.Contains(err.Error(), "reverses the explicitly stated relation direction")) { t.Fatalf("reversed direction passed: %v", err) } if !tc.reversed && err != nil { t.Fatalf("valid direction rejected: %v", err) } }) } } func TestGraphUsageProcedureCannotTreatProductAsPrerequisiteGoal(t *testing.T) { const source = "使用本品前先清洗双手,然后取本品涂于手部。" for _, target := range []string{"本品", "本产品", "该产品", "本品(未命名)"} { graph := &types.GraphData{Relation: []*types.GraphRelation{{ Node1: "先清洗双手,然后取本品涂于手部", Node2: target, Type: "先修条件", Evidence: source, }}} if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "product itself as a prerequisite goal") { t.Fatalf("invalid product prerequisite accepted for %q: %v", target, err) } } for _, relation := range []*types.GraphRelation{ {Node1: "清洗双手", Node2: "取本品涂于手部", Type: "先修条件", Evidence: source}, {Node1: "本品", Node2: "先清洗双手,然后取本品涂于手部", Type: "约束", Evidence: source}, } { if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil { t.Fatalf("supported action relation rejected: %+v: %v", relation, err) } } }