Files
xinmi/internal/application/service/chat_pipeline/extract_entity_semantics_test.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

289 lines
19 KiB
Go

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