Files
xinmi/internal/application/service/chat_pipeline/extract_entity_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

819 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package chatpipeline
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/types"
)
type extractionResponseChat struct {
response *types.ChatResponse
responses []*types.ChatResponse
calls int
options *chat.ChatOptions
messages []chat.Message
}
func (m *extractionResponseChat) Chat(_ context.Context, messages []chat.Message, options *chat.ChatOptions) (*types.ChatResponse, error) {
m.options = options
m.messages = messages
m.calls++
if len(m.responses) >= m.calls {
return m.responses[m.calls-1], nil
}
return m.response, nil
}
func (*extractionResponseChat) ChatStream(context.Context, []chat.Message, *chat.ChatOptions) (<-chan types.StreamResponse, error) {
panic("unexpected streaming extraction")
}
func (*extractionResponseChat) GetModelName() string { return "extraction-test" }
func (*extractionResponseChat) GetModelID() string { return "extraction-test" }
func TestExtractorRequiresCompletedGraphContent(t *testing.T) {
for _, tc := range []struct {
name string
response *types.ChatResponse
wantErr string
}{
{name: "missing response", wantErr: "no response"},
{
name: "reasoning exhausted the token budget",
response: &types.ChatResponse{
FinishReason: "length", ReasoningContent: "private reasoning",
Usage: types.TokenUsage{CompletionTokens: 4096},
},
wantErr: "exceeded the output token limit",
},
{
name: "valid JSON cannot hide token exhaustion",
response: &types.ChatResponse{
Content: `[{"entity":"Alice"}]`, FinishReason: "length",
},
wantErr: "exceeded the output token limit",
},
{
name: "reasoning is not a graph response",
response: &types.ChatResponse{
Content: " \n\t", ReasoningContent: `[{"entity":"private reasoning"}]`, FinishReason: "stop",
},
wantErr: "empty content",
},
{
name: "explicit empty result succeeds",
response: &types.ChatResponse{Content: `[]`, FinishReason: "stop"},
},
{
name: "unknown object is not an empty result",
response: &types.ChatResponse{Content: `{"unexpected":true}`, FinishReason: "stop"},
wantErr: "no recognized graph extractions",
},
} {
t.Run(tc.name, func(t *testing.T) {
model := &extractionResponseChat{response: tc.response}
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract entities"})
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
if model.options == nil || model.options.Thinking == nil || *model.options.Thinking {
t.Fatal("entity extraction must explicitly disable thinking")
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want %q", err, tc.wantErr)
}
if strings.Contains(err.Error(), "private reasoning") {
t.Fatal("error must not expose reasoning content")
}
if graph != nil {
t.Fatalf("failed extraction returned graph: %+v", graph)
}
return
}
if err != nil || graph == nil || len(graph.Node) != 0 || len(graph.Relation) != 0 {
t.Fatalf("explicit empty result = %+v, %v", graph, err)
}
})
}
}
func TestGraphExtractorRequiresSourceEvidence(t *testing.T) {
// These contraindications are from a production product-information chunk,
// including its OCR typo; the failed extraction claimed positive suitability.
const productWarnings = "私蜜妍+蜜桃初醒套盒系列\n3 . 过敏者慎甩 4 . 粘膜破损处禁用 ;\n5 . 不得用于性生活中对性病的预防 。\n〖 警示说明 〗 本品不是药品 , 不具有治疗 、 护理 、 保健作用 。"
for _, tc := range []struct {
name, content, from, to, relation, evidence, wantErr string
}{
{
name: "missing evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于",
wantErr: "requires exact evidence",
},
{
name: "invented evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于儿童",
wantErr: "requires exact evidence",
},
{
name: "whitespace is part of source", content: "本品 适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
wantErr: "requires exact evidence",
},
{
name: "exact positive evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
},
{
name: "real allergy warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "过敏者", relation: "适用于", evidence: "过敏者慎甩",
wantErr: "conflicts with a source restriction",
},
{
name: "real allergy warning reversed", content: productWarnings, from: "过敏者", to: "本品", relation: "适用于", evidence: "过敏者慎甩",
wantErr: "conflicts with a source restriction",
},
{
name: "real damaged mucosa warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "粘膜破损处", relation: "适用于", evidence: "粘膜破损处禁用",
wantErr: "conflicts with a source restriction",
},
{
name: "real STI prevention warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "适用于", evidence: "不得用于性生活中对性病的预防",
wantErr: "conflicts with a source restriction",
},
{
name: "quote cannot hide preceding negation", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "用于", evidence: "用于性生活中对性病的预防",
wantErr: "conflicts with a source restriction",
},
{
name: "target alias cannot hide preceding negation", content: "本品不得用于性病预防。", from: "本品", to: "预防性病", relation: "用于", evidence: "用于性病预防",
wantErr: "conflicts with a source restriction",
},
{
name: "real disclaimer list", content: productWarnings, from: "本品", to: "护理", relation: "具有", evidence: "不具有治疗 、 护理 、 保健作用",
wantErr: "conflicts with a source restriction",
},
{
name: "comma separated negative list", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "护理", relation: "具有", evidence: "护理",
wantErr: "conflicts with a source restriction",
},
{
name: "later comma separated negative list item", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "保健作用", relation: "具有", evidence: "保健作用",
wantErr: "conflicts with a source restriction",
},
{
name: "multiple negative clauses", content: "本品不得用于性病预防,也不具有治疗作用。", from: "本品", to: "治疗作用", relation: "具有", evidence: "治疗作用",
wantErr: "conflicts with a source restriction",
},
{
name: "accurate caution preserved", content: productWarnings, from: "本品", to: "过敏者", relation: "慎用于", evidence: "过敏者慎甩",
},
{
name: "accurate prohibition preserved", content: productWarnings, from: "本品", to: "粘膜破损处", relation: "禁用于", evidence: "粘膜破损处禁用",
},
{
name: "accurate non applicability preserved", content: productWarnings, from: "本品", to: "性生活中对性病的预防", relation: "不适用于", evidence: "不得用于性生活中对性病的预防",
},
{
name: "avoid contact prohibition preserved", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 。", from: "本品", to: "眼睛", relation: "禁用于", evidence: "避免接触眼睛",
},
{
name: "avoid contact restriction with exposure alias", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用。", from: "本品", to: "入眼", relation: "禁用于", evidence: "避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用",
},
{
name: "avoid contact is not positive use", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "适用于", evidence: "本品避免接触眼睛",
wantErr: "conflicts with a source restriction",
},
{
name: "avoid contact is not conditional permission", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "条件适用于", evidence: "本品避免接触眼睛",
wantErr: "conflicts with a source restriction",
},
{
name: "avoid contact is not caution", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "慎用于", evidence: "本品避免接触眼睛",
wantErr: "weakens a source prohibition",
},
{
name: "benefit avoiding discomfort is not prohibition", content: "本品帮助避免干燥引起的不适。", from: "本品", to: "干燥引起的不适", relation: "禁用于", evidence: "本品帮助避免干燥引起的不适",
wantErr: "negative use label without a source restriction",
},
{
name: "damaged OCR restriction remains unresolved", content: "避女性三期僮生 ( 羟期 . 孕期飞产期 } 。", from: "本品", to: "女性三期(羟期.孕期飞产期)", relation: "禁用于", evidence: "避女性三期僮生 ( 羟期 . 孕期飞产期 }",
wantErr: "negative use label without a source restriction",
},
{
name: "accidental exposure response is not suitability", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "眼部接触", relation: "条件适用于", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
wantErr: "without an explicit source use claim",
},
{
name: "accidental exposure response remains a constraint", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "不慎入眼后立即用清水冲洗", relation: "约束", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
},
{
name: "unrelated warning in same sentence", content: "本品适用于成人,过敏者禁用。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人,过敏者禁用",
},
{
name: "unrelated warning before positive claim", content: "过敏者禁用,但本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "过敏者禁用,但本品适用于成人",
},
{
name: "unrelated condition before positive claim", content: "如果不适请停用,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "如果不适请停用,本品适用于成人",
},
{
name: "unrelated disclaimer in previous sentence", content: "本品不是药品,不具有治疗作用。本品适用于成人日常清洁。", from: "本品", to: "成人日常清洁", relation: "适用于", evidence: "本品适用于成人日常清洁",
},
{
name: "OCR sections without punctuation", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "阴道黏膜", relation: "适用于", evidence: "用于阴道黏膜抑菌",
},
{
name: "restriction inside OCR section remains enforced", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "治疗疾病", relation: "适用于", evidence: "用于治疗疾病或者改善症状",
wantErr: "conflicts with a source restriction",
},
{
name: "should not be used restriction", content: "本品不应用于性生活中对性病的预防;", from: "本品", to: "性生活中对性病的预防", relation: "适用于", evidence: "用于性生活中对性病的预防",
wantErr: "conflicts with a source restriction",
},
{
name: "unrelated negative adjective", content: "本品用于改善不良习惯。", from: "本品", to: "不良习惯", relation: "改善", evidence: "本品用于改善不良习惯",
},
{
name: "conditional suitability cannot become unconditional", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
wantErr: "conditional source claim",
},
{
name: "inline conditional suitability", content: "本品仅限在医师指导下适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品仅限在医师指导下适用于成人",
wantErr: "conditional source claim",
},
{
name: "conditional label preserved", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "仅在医师指导下,本品适用于成人",
},
{
name: "conditional use requires a real condition", content: "本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
wantErr: "without an explicit condition",
},
{
name: "conditional use evidence retains the condition", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
wantErr: "without an explicit condition",
},
{
name: "conditional use cannot substitute a condition for its use target", content: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用。", from: "本品", to: "过敏现象", relation: "条件适用于", evidence: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用",
wantErr: "without a source-grounded use target",
},
{
name: "conditional procedure retains source use target", content: "清洗外阴后 , 将凝胶推入阴道。", from: "凝胶", to: "阴道", relation: "条件适用于", evidence: "清洗外阴后 , 将凝胶推入阴道",
},
{
name: "ordinary causal condition is not product suitability", content: "如果持续降价,降价导致利润下降。", from: "降价", to: "利润下降", relation: "导致", evidence: "如果持续降价,降价导致利润下降",
},
{
name: "ordinary support condition is not product suitability", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "只有认真倾听,倾听支持成交",
},
{
name: "ordinary conditional evidence cannot omit condition", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
wantErr: "omits a source condition",
},
{
name: "unrelated warning before general support", content: "不得强行推销,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
},
{
name: "explicit negative causation", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不导致客户忠诚",
},
{
name: "explicit future negative causation", content: "降价不会导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不会导致客户忠诚",
},
{
name: "explicit negative support", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "不支持", evidence: "强行推销不支持客户信任",
},
{
name: "negative causation cannot be positive", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "导致", evidence: "降价不导致客户忠诚",
wantErr: "conflicts with a source restriction",
},
{
name: "negative support cannot be positive", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "支持", evidence: "强行推销不支持客户信任",
wantErr: "conflicts with a source restriction",
},
{
name: "use prohibition cannot imply negative causation", content: "本品不得用于疾病治疗。", from: "本品", to: "疾病治疗", relation: "不导致", evidence: "本品不得用于疾病治疗",
wantErr: "negative use label without a source restriction",
},
{
name: "combined use cannot become blanket ingredient prohibition", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "阴离子成分", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
wantErr: "omits the combined-use scope",
},
{
name: "combined use prohibition preserves operation", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "与阴离子成分同时使用", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
},
{
name: "combined use constraint preserves operation", content: "本品不可与其他产品一起使用。", from: "本品", to: "不可与其他产品合用", relation: "约束", evidence: "本品不可与其他产品一起使用",
},
{
name: "substitution cannot become blanket drug prohibition", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "药品", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
wantErr: "omits the substitution scope",
},
{
name: "substitution prohibition preserves operation", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "代替药品使用", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
},
{
name: "substitution constraint preserves operation", content: "本品不得替代药品。", from: "本品", to: "代替药品", relation: "约束", evidence: "本品不得替代药品",
},
{
name: "constraint preserved", content: "本品只限于完整皮肤。", from: "本品", to: "完整皮肤", relation: "约束", evidence: "本品只限于完整皮肤",
},
{
name: "avoidance is not conditional permission", content: "用于阴道时 , 注 0 避开女性崆期 、 产期 。", from: "本品", to: "产期", relation: "条件适用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
wantErr: "conflicts with a source restriction",
},
{
name: "do not use is not caution", content: "注意三期勿用。", from: "本品", to: "三期", relation: "慎用于", evidence: "注意三期勿用",
wantErr: "weakens a source prohibition",
},
{
name: "stop use cannot become caution", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "如有不适", relation: "慎用于", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
wantErr: "weakens a source prohibition",
},
{
name: "truncated quote cannot hide stop use", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适", relation: "慎用于", evidence: "如有不适",
wantErr: "weakens a source prohibition",
},
{
name: "stop use constraint preserved", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适时立即停止使用", relation: "约束", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
},
{
name: "suppression is not support of first microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
wantErr: "reverses source suppression",
},
{
name: "suppression is not support of last microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "大肠杆菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
wantErr: "reverses source suppression",
},
{
name: "literal suppression relation preserved", content: "本品对白色念珠菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有抑制作用",
},
{
name: "suppression benefit is not reversed", content: "本品支持阴道抑菌。", from: "本品", to: "阴道抑菌", relation: "支持", evidence: "本品支持阴道抑菌",
},
{
name: "benefit is not non applicability", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不适用于", evidence: "精华凝露激发内在欲望,告别冷淡",
wantErr: "negative use label without a source restriction",
},
{
name: "benefit is not denied support", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不支持", evidence: "精华凝露激发内在欲望,告别冷淡",
wantErr: "negative use label without a source restriction",
},
{
name: "explicit denial preserved", content: "本品不具有治疗 、 护理 、 保健作用。", from: "本品", to: "治疗、护理、保健作用", relation: "不支持", evidence: "本品不具有治疗 、 护理 、 保健作用",
},
{
name: "body site cannot be removed from prohibition", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
wantErr: "omits the body-site scope",
},
{
name: "scoped site prohibition preserved", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期乳头使用", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
},
{
name: "unrelated site prohibition not borrowed", content: "注意三期勿用 , 哺乳期请勿在乳头使用。", from: "本品", to: "三期", relation: "禁用于", evidence: "注意三期勿用 , 哺乳期请勿在乳头使用",
},
{
name: "use scope cannot be removed from prohibition", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
wantErr: "omits the use scope",
},
{
name: "scoped use prohibition preserved", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期阴道使用", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
},
{
name: "invalid self relation is not silently discarded", content: "本品适用于成人", from: "本品", to: "本品", relation: "适用于", evidence: "本品适用于成人",
wantErr: "is invalid",
},
{
name: "quantity endpoint must match evidence", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
wantErr: "endpoint quantity absent",
},
{
name: "quantity cannot come from another product", content: "紧紧丹富含3350ppm多肽。另一产品含6650ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
wantErr: "endpoint quantity absent",
},
{
name: "quantity cannot match numeric substring", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
wantErr: "endpoint quantity absent",
},
{
name: "exact quantity preserved", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "3350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
},
{
name: "OCR decimal typography preserves amount", content: "本品含乳酸 2 . 0 % 。", from: "本品", to: "乳酸2.0%", relation: "包含", evidence: "本品含乳酸 2 . 0 %",
},
{
name: "antibacterial activity is not suppressed", content: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复", from: "私蜜叫溶菌酶", to: "抑菌", relation: "抑制", evidence: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复",
wantErr: "no explicit suppression predicate",
},
{
name: "suppression predicate must be explicit", content: "本品对白色念珠菌有效。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有效",
wantErr: "no explicit suppression predicate",
},
{
name: "explicit suppression of activity is preserved", content: "此成分抑制抑菌反应。", from: "此成分", to: "抑菌反应", relation: "抑制", evidence: "此成分抑制抑菌反应",
},
{
name: "antimicrobial predicate preserved", content: "本品对金黄色葡萄球菌有抗菌作用。", from: "本品", to: "金黄色葡萄球菌", relation: "抑制", evidence: "本品对金黄色葡萄球菌有抗菌作用",
},
{
name: "storage is not conditional suitability", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "条件适用于", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
wantErr: "misclassifies a storage constraint",
},
{
name: "storage condition retained", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
},
{
name: "use cannot be inferred from benefit", content: "精华凝露提高女性阴蒂、阴道、G点部位的敏感度。", from: "精华凝露", to: "女性阴蒂、阴道、G点部位", relation: "适用于", evidence: "提高女性阴蒂、阴道、G点部位的敏感度",
wantErr: "without an explicit source use claim",
},
{
name: "use cannot be inferred from OCR fragment", content: "本品荫堆崆丰部。", from: "本品", to: "阴道", relation: "条件适用于", evidence: "本品荫堆崆丰部",
wantErr: "without an explicit source use claim",
},
{
name: "preceding fragment cannot belong to later product", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "白番茄胶原蛋白肽饮品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
wantErr: "earlier unnamed product fragment",
},
{
name: "unresolved preceding product preserved", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
},
{
name: "same warning in named product also supported", content: "如有不适请停止使用。\n\n〔 产品名称 〗 柔肤霜。如有不适请停止使用。", from: "柔肤霜", to: "不适时停止使用", relation: "约束", evidence: "如有不适请停止使用",
},
} {
t.Run(tc.name, func(t *testing.T) {
encoded, err := json.Marshal([]map[string]string{{
"entity1": tc.from, "entity2": tc.to, "relation": tc.relation, "evidence": tc.evidence,
}})
if err != nil {
t.Fatal(err)
}
model := &extractionResponseChat{response: &types.ChatResponse{Content: string(encoded), FinishReason: "stop"}}
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
graph, err := extractor.Extract(context.Background(), tc.content)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || graph != nil {
t.Fatalf("graph = %+v, error = %v; want nil graph and %q", graph, err, tc.wantErr)
}
return
}
if err != nil || graph == nil || len(graph.Relation) != 1 {
t.Fatalf("graph = %+v, error = %v; want supported relation", graph, err)
}
if got := graph.Relation[0]; got.Evidence != tc.evidence || got.Type != tc.relation {
t.Fatalf("relation was rewritten: %+v", got)
}
})
}
}
func TestGraphExtractorContractDoesNotChangeQueryExtraction(t *testing.T) {
template := &types.PromptTemplateStructured{
Description: "Extract graph",
Examples: []types.GraphData{{Text: "old positive-only example", Node: []*types.GraphNode{{Name: "example entity"}}}},
}
model := &extractionResponseChat{response: &types.ChatResponse{
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"knows"}]`, FinishReason: "stop",
}}
queryExtractor := NewExtractor(model, template)
graph, err := queryExtractor.Extract(context.Background(), "Alice knows Bob")
if err != nil || len(graph.Relation) != 1 {
t.Fatalf("query extraction = %+v, %v", graph, err)
}
if strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
t.Fatal("query extraction received graph-only instructions")
}
graphExtractor := NewGraphExtractor(model, template)
if _, err := graphExtractor.Extract(context.Background(), "Alice knows Bob"); err == nil {
t.Fatal("persisted graph accepted a relation without evidence")
}
if len(template.Examples) != 1 || len(graphExtractor.template.Examples) != 0 {
t.Fatal("graph extraction mutated shared examples or kept ungrounded examples")
}
if !strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
t.Fatal("graph extraction is missing its evidence instructions")
}
}
func TestGraphExtractorRejectsPartialAndMalformedResults(t *testing.T) {
for _, response := range []string{
`[{"entity":"Alice"},{"unexpected":"ignored relationship"}]`,
`[{"entity":42}]`,
`[{"entity":"Alice","entity1":"Alice","entity2":"Bob","relation":"knows"}]`,
`[{"entity":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"}]`,
`[{"entity1":"Alice","entity2":"Bob","evidence":"Alice knows Bob"}]`,
`[{"entity1":"Alice","entity2":"Bob","relation":42,"evidence":"Alice knows Bob"}]`,
`[{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":42}]`,
`[{"entity":"Alice"},{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"},{"entity1":"Bob","entity2":"Charlie","relation":"knows","evidence":"invented"}]`,
} {
model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}}
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
if err == nil || graph != nil {
t.Fatalf("accepted partial or malformed result: %+v, %v", graph, err)
}
}
}
func TestGraphExtractorRejectsUnknownLabelsWithoutDroppingRelations(t *testing.T) {
model := &extractionResponseChat{response: &types.ChatResponse{
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"invented","evidence":"Alice knows Bob"}]`, FinishReason: "stop",
}}
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{
Description: "Use these types: %s", Tags: []string{"knows"},
})
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
if graph != nil || err == nil || !strings.Contains(err.Error(), "outside the configured relation types") {
t.Fatalf("graph = %+v, err = %v", graph, err)
}
}
func TestGraphExtractorCorrectsValidationOnce(t *testing.T) {
const source = "〔 注意事项 〗 不得用于治疗疾病 。"
const repaired = `[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`
for _, invalid := range []string{
`[{"entity":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`,
`[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"〖 注意事项 〗 不得用于治疗疾病 。"}]`,
`[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`,
} {
model := &extractionResponseChat{responses: []*types.ChatResponse{
{Content: invalid, FinishReason: "stop"}, {Content: repaired, 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("correction = %+v, %v, calls=%d", graph, err, model.calls)
}
if graph.Relation[0].Type != "不适用于" || graph.Relation[0].Evidence != "不得用于治疗疾病" {
t.Fatalf("corrected relation changed: %+v", graph.Relation[0])
}
if len(model.messages) != 4 || model.messages[2].Content != invalid ||
!strings.Contains(model.messages[3].Content, "failed validation") {
t.Fatal("correction did not include rejected output and validation feedback")
}
}
model := &extractionResponseChat{response: &types.ChatResponse{
Content: `[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`, FinishReason: "stop",
}}
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
graph, err := extractor.Extract(context.Background(), source)
if graph != nil || err == nil || model.calls != 2 {
t.Fatalf("invalid correction was accepted or retried indefinitely: %+v, %v, calls=%d", graph, err, model.calls)
}
model.responses = []*types.ChatResponse{model.response, {Content: "[]", FinishReason: "stop"}}
model.calls = 0
graph, err = extractor.Extract(context.Background(), source)
if graph != nil || err == nil || !strings.Contains(err.Error(), "no extracted content") {
t.Fatalf("empty correction hid a validation failure: %+v, %v", graph, err)
}
}
// TestFormater_ParseGraph_FenceVariants exercises the JSON parsing path used
// by the graph extraction pipeline against the LLM response shapes that
// caused issue #1113. Each case feeds a raw LLM response string to
// Formater.ParseGraph and asserts the resulting graph data, or that the
// error path is preserved for genuinely invalid input.
func TestFormater_ParseGraph_FenceVariants(t *testing.T) {
const validJSON = `[
{"entity": "Alice", "entity_attributes": ["person"]},
{"entity": "Bob", "entity_attributes": ["person"]},
{"entity1": "Alice", "entity2": "Bob", "relation": "knows"}
]`
cases := []struct {
name string
input string
wantNodes int
wantRels int
wantErr bool
errContains string
}{
{
name: "wrapped in ```json fence",
input: "```json\n" + validJSON + "\n```",
wantNodes: 2,
wantRels: 1,
},
{
name: "wrapped in plain ``` fence (no language tag)",
input: "```\n" + validJSON + "\n```",
wantNodes: 2,
wantRels: 1,
},
{
name: "no fences at all (raw JSON)",
input: validJSON,
wantNodes: 2,
wantRels: 1,
},
{
name: "leading prose then ```json fence",
input: "Here is the extracted graph:\n\n```json\n" + validJSON + "\n```",
wantNodes: 2,
wantRels: 1,
},
{
name: "trailing prose after closing fence",
input: "```json\n" + validJSON + "\n```\n\nHope this helps!",
wantNodes: 2,
wantRels: 1,
},
{
name: "extra surrounding whitespace and newlines",
input: "\n\n ```json\n\n" + validJSON + "\n\n``` \n",
wantNodes: 2,
wantRels: 1,
},
{
// Issue #1113 Pattern 3: LLM hit max_tokens, no closing fence.
// The response is structurally a JSON array we can still parse.
name: "truncated response, opening ```json fence with no closer",
input: "```json\n" + validJSON,
wantNodes: 2,
wantRels: 1,
},
{
// Issue #1113 Pattern 1: bare backticks/markdown around JSON
// without a well-formed fence pair.
name: "stray backticks around JSON",
input: "`" + validJSON + "`",
wantNodes: 2,
wantRels: 1,
},
{
name: "JSON object embedded in prose (single dict)",
input: "Result: {\"entity\": \"Alice\", \"entity_attributes\": [\"person\"]} -- end.",
wantNodes: 1,
wantRels: 0,
},
{
name: "empty input",
input: "",
wantErr: true,
errContains: "empty",
},
{
name: "whitespace only",
input: " \n\t ",
wantErr: true,
errContains: "empty",
},
{
name: "fenced but body is invalid JSON",
input: "```json\nnot json at all\n```",
wantErr: true,
errContains: "parse",
},
{
name: "no recoverable JSON, only prose",
input: "Sorry, I cannot extract a graph from this text.",
wantErr: true,
errContains: "parse",
},
}
ctx := context.Background()
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
f := NewFormater()
graph, err := f.ParseGraph(ctx, tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (graph=%+v)", graph)
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.errContains)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graph == nil {
t.Fatalf("expected non-nil graph")
}
if got := len(graph.Node); got != tc.wantNodes {
t.Errorf("nodes: got %d, want %d (graph=%+v)", got, tc.wantNodes, graph)
}
if got := len(graph.Relation); got != tc.wantRels {
t.Errorf("relations: got %d, want %d (graph=%+v)", got, tc.wantRels, graph)
}
})
}
}
// TestExtractJSONLike covers the JSON-substring extraction helper in
// isolation. The helper is used by the fallback path in extractContent when
// fences are missing or malformed.
func TestExtractJSONLike(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"plain object", `{"a": 1}`, `{"a": 1}`},
{"plain array", `[1, 2, 3]`, `[1, 2, 3]`},
{"object in prose", `noise {"a": 1} tail`, `{"a": 1}`},
{"array preferred when first", `tail [1] {"a":1}`, `[1]`},
{"object preferred when first", `tail {"a":1} [1]`, `{"a":1}`},
{"nested braces", `{"a": {"b": [1,2]}}`, `{"a": {"b": [1,2]}}`},
{"brace inside string literal", `{"a": "}{not real}"}`, `{"a": "}{not real}"}`},
{"escaped quote inside string", `{"a": "he said \"hi\""}`, `{"a": "he said \"hi\""}`},
{"unbalanced object returns empty", `{"a": 1`, ""},
{"no json", `just words`, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := extractJSONLike(tc.in)
if got != tc.want {
t.Errorf("extractJSONLike(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestStripFencesAndExtract focuses on the fence-recovery helper used as a
// last resort when the main fence regex fails. Behavior must remain
// conservative: return an empty string when nothing plausible can be
// recovered, so the caller can fall through to existing behavior.
func TestStripFencesAndExtract(t *testing.T) {
cases := []struct {
name string
in string
format FormatType
want string
}{
{
name: "open fence with json tag and no close",
in: "```json\n{\"a\":1}",
format: FormatTypeJSON,
want: `{"a":1}`,
},
{
name: "open fence with no tag and no close",
in: "```\n[1,2]",
format: FormatTypeJSON,
want: `[1,2]`,
},
{
name: "well-formed fence still recovers body",
in: "```json\n{\"a\":1}\n```",
format: FormatTypeJSON,
want: `{"a":1}`,
},
{
name: "no fence but embedded json object",
in: "Sure! {\"a\":1} done.",
format: FormatTypeJSON,
want: `{"a":1}`,
},
{
name: "no fence and no json",
in: "just prose",
format: FormatTypeJSON,
want: "",
},
{
name: "empty input",
in: "",
format: FormatTypeJSON,
want: "",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := stripFencesAndExtract(tc.in, tc.format)
if got != tc.want {
t.Errorf("stripFencesAndExtract(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestIsLikelyLanguageTag guards the heuristic used to drop language-tag
// lines after an opening fence in the recovery path.
func TestIsLikelyLanguageTag(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"json", true},
{"yaml", true},
{"yml", true},
{"go", true},
{"c++", true},
{"objective-c", true},
{"", false},
{"this is not a tag", false},
{`{"a":1}`, false},
{strings.Repeat("a", 17), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.in, func(t *testing.T) {
if got := isLikelyLanguageTag(tc.in); got != tc.want {
t.Errorf("isLikelyLanguageTag(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
}