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
819 lines
40 KiB
Go
819 lines
40 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|