mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 05:23:47 +08:00
Ports doc-level auto-metadata extraction to Go and adds the knowledge_compiler component with scheduler/routing. Fixes Extractor metadata injection type assertion and enable_metadata default-on.
614 lines
20 KiB
Go
614 lines
20 KiB
Go
package structure
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/cespare/xxhash/v2"
|
|
|
|
"ragflow/internal/ingestion/component/knowledge_compiler/common"
|
|
)
|
|
|
|
// ---- mocks ----
|
|
|
|
// graphChat answers the three LLM call shapes of the structure variant:
|
|
// node extraction, edge extraction, and merge judging. It emits items for
|
|
// every fixture phrase present in the user prompt so assertions hold whether
|
|
// the chunks pack into one batch or several.
|
|
type graphChat struct {
|
|
mergeCalls int
|
|
nodeCalls int
|
|
edgeCalls int
|
|
}
|
|
|
|
var graphFixtures = []struct {
|
|
phrase string
|
|
entities []map[string]any
|
|
relations []map[string]any
|
|
}{
|
|
{
|
|
phrase: "Alpha is a Beta",
|
|
entities: []map[string]any{
|
|
{"type": "letter", "name": "Alpha", "description": "the letter Alpha"},
|
|
{"type": "letter", "name": "Beta", "description": "the letter Beta"},
|
|
},
|
|
relations: []map[string]any{
|
|
{"type": "linked", "source": "Alpha", "target": "Beta", "description": "Alpha is a Beta"},
|
|
},
|
|
},
|
|
{
|
|
phrase: "Beta related to Gamma",
|
|
entities: []map[string]any{
|
|
{"type": "letter", "name": "Beta", "description": "the letter Beta"},
|
|
{"type": "letter", "name": "Gamma", "description": "the letter Gamma"},
|
|
},
|
|
relations: []map[string]any{
|
|
{"type": "linked", "source": "Beta", "target": "Gamma", "description": "Beta related to Gamma"},
|
|
},
|
|
},
|
|
{
|
|
phrase: "Al partners with Gamma",
|
|
entities: []map[string]any{
|
|
{"type": "letter", "name": "Al", "description": "short for Alpha"},
|
|
{"type": "letter", "name": "Gamma", "description": "the letter Gamma"},
|
|
},
|
|
relations: []map[string]any{
|
|
{"type": "partners_with", "source": "Al", "target": "Gamma", "description": "Al partners with Gamma"},
|
|
},
|
|
},
|
|
{
|
|
phrase: "Alpha also links Gamma",
|
|
entities: []map[string]any{
|
|
{"type": "letter", "name": "Alpha", "description": "the letter Alpha"},
|
|
{"type": "letter", "name": "Gamma", "description": "the letter Gamma"},
|
|
},
|
|
relations: []map[string]any{
|
|
{"type": "linked", "source": "Alpha", "target": "Gamma", "description": "Alpha also links Gamma"},
|
|
},
|
|
},
|
|
}
|
|
|
|
func itemsJSON(items []map[string]any, chunkIDs []string) string {
|
|
for _, item := range items {
|
|
item["source_chunk_ids"] = chunkIDs
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"items": items})
|
|
return string(b)
|
|
}
|
|
|
|
func chunkIDsFromPrompt(prompt string) []string {
|
|
var ids []string
|
|
for _, id := range []string{"c1", "c2", "c3"} {
|
|
if strings.Contains(prompt, "[CHUNK_ID: "+id+"]") {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return []string{"c1"}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func (m *graphChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
|
|
ids := chunkIDsFromPrompt(req.UserPrompt)
|
|
switch {
|
|
case strings.HasPrefix(req.UserPrompt, "Item A (existing):"):
|
|
m.mergeCalls++
|
|
a, b := parseMergeItems(req.UserPrompt)
|
|
if sameLogicalItem(a, b) {
|
|
return &common.ChatResponse{Content: fmt.Sprintf(`{"duplicated":true,"merged":%s}`, payloadJSON(a))}, nil
|
|
}
|
|
return &common.ChatResponse{Content: `{"duplicated":false,"merged":null}`}, nil
|
|
case strings.Contains(req.SystemPrompt, "strict-chain constraint"):
|
|
// Chain correction: keep Alpha→Beta, drop the fan-out partner.
|
|
return &common.ChatResponse{Content: `{"keep":[{"from":"Alpha","to":"Beta"}]}`}, nil
|
|
case strings.Contains(req.SystemPrompt, "## Known Entities:"):
|
|
m.edgeCalls++
|
|
var rels []map[string]any
|
|
for _, f := range graphFixtures {
|
|
if strings.Contains(req.UserPrompt, f.phrase) {
|
|
for _, r := range f.relations {
|
|
rels = append(rels, copyPayload(r))
|
|
}
|
|
}
|
|
}
|
|
return &common.ChatResponse{Content: itemsJSON(rels, ids)}, nil
|
|
default:
|
|
m.nodeCalls++
|
|
var ents []map[string]any
|
|
for _, f := range graphFixtures {
|
|
if strings.Contains(req.UserPrompt, f.phrase) {
|
|
for _, e := range f.entities {
|
|
ents = append(ents, copyPayload(e))
|
|
}
|
|
}
|
|
}
|
|
return &common.ChatResponse{Content: itemsJSON(ents, ids)}, nil
|
|
}
|
|
}
|
|
|
|
func copyPayload(p map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
for k, v := range p {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseMergeItems extracts the Item A / Item B payload JSONs from a merge
|
|
// judge user prompt.
|
|
func parseMergeItems(prompt string) (map[string]any, map[string]any) {
|
|
body := strings.TrimPrefix(prompt, "Item A (existing):\n")
|
|
parts := strings.SplitN(body, "\n\nItem B (incoming):\n", 2)
|
|
if len(parts) != 2 {
|
|
return nil, nil
|
|
}
|
|
return parsePayload(parts[0]), parsePayload(parts[1])
|
|
}
|
|
|
|
// sameLogicalItem mirrors what a reasonable judge would decide on the
|
|
// fixtures: entities are duplicates when their names match (or the known
|
|
// Al≡Alpha alias pair); relations when source+target+type all match.
|
|
func sameLogicalItem(a, b map[string]any) bool {
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
aName, bName := entityName(a), entityName(b)
|
|
if aName != "" && bName != "" {
|
|
if aName == bName {
|
|
return true
|
|
}
|
|
pair := map[string]bool{aName: true, bName: true}
|
|
return pair["Al"] && pair["Alpha"]
|
|
}
|
|
as, bs := relationEndpoint(a, "", "source"), relationEndpoint(b, "", "source")
|
|
at, bt := relationEndpoint(a, "", "target"), relationEndpoint(b, "", "target")
|
|
return as != "" && as == bs && at == bt && stringOf(a["type"]) == stringOf(b["type"])
|
|
}
|
|
|
|
// hashEmbedder is content-deterministic: identical text => identical (unit)
|
|
// vector => cosine 1.0. Near-identical texts do NOT collide, so only exact
|
|
// duplicate payloads reach the LLM judge.
|
|
type hashEmbedder struct{ dim int }
|
|
|
|
func (m hashEmbedder) Dimensions() int { return m.dim }
|
|
|
|
func (m hashEmbedder) Encode(_ context.Context, texts []string) ([][]float32, error) {
|
|
out := make([][]float32, len(texts))
|
|
for i, t := range texts {
|
|
v := make([]float32, m.dim)
|
|
for j := 0; j < m.dim; j++ {
|
|
h := xxhash.Sum64String(fmt.Sprintf("%d:%s", j, t))
|
|
v[j] = float32(int64(h%(1<<20))-(1<<19)) / float32(1<<19)
|
|
}
|
|
var s float64
|
|
for _, x := range v {
|
|
s += float64(x) * float64(x)
|
|
}
|
|
if s = math.Sqrt(s); s > 0 {
|
|
for k := range v {
|
|
v[k] = float32(float64(v[k]) / s)
|
|
}
|
|
}
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// constEmbedder maps every text to the same vector, so every pair in a dedup
|
|
// bucket reaches the LLM judge (used to exercise alias merges deterministically).
|
|
type constEmbedder struct{ dim int }
|
|
|
|
func (m constEmbedder) Dimensions() int { return m.dim }
|
|
func (m constEmbedder) Encode(_ context.Context, texts []string) ([][]float32, error) {
|
|
v := make([]float32, m.dim)
|
|
for i := range v {
|
|
v[i] = 1
|
|
}
|
|
out := make([][]float32, len(texts))
|
|
for i := range out {
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func graphParserConfig() map[string]any {
|
|
return map[string]any{
|
|
"kind": "graph",
|
|
"entity": map[string]any{"fields": []any{
|
|
map[string]any{"type": "letter", "description": "a single letter entity"},
|
|
}},
|
|
"relation": map[string]any{"fields": []any{
|
|
map[string]any{"type": "linked", "description": "a link between letters"},
|
|
map[string]any{"type": "partners_with", "description": "a partnership"},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func testParam() common.Param {
|
|
p := common.Param{}.Defaults()
|
|
p.Variant = common.VariantStructure
|
|
p.SimilarityThreshold = 0.99
|
|
p.MaxWorkers = 1
|
|
return p
|
|
}
|
|
|
|
// ---- prompt alignment tests ----
|
|
|
|
func TestHypergraphPromptsTemplateShape(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"kind": "graph",
|
|
"guideline": map[string]any{"target": "Extract the letter graph.", "rules_for_entities": "Be precise.", "rules_for_relations": "Only stated links.", "rules_for_time": "As of {observation_time}."},
|
|
"global_rules": "No inventions.",
|
|
"entity": map[string]any{
|
|
"description": "Letter entities.",
|
|
"fields": []any{map[string]any{"type": "letter", "description": "a letter", "rule": "single glyph"}},
|
|
},
|
|
"relation": map[string]any{
|
|
"description": "Letter links.",
|
|
"fields": []any{map[string]any{"type": "linked", "description": "a link"}},
|
|
},
|
|
}
|
|
node, edge := HypergraphPrompts(cfg, "en")
|
|
|
|
for _, want := range []string{
|
|
"# Role and Task:\nExtract the letter graph.",
|
|
"## Global Rules:\nNo inventions.",
|
|
"## Entity Extraction Rules:\nBe precise.",
|
|
"## Entity Description:\nLetter entities.",
|
|
"## Entity Fields:\n- type: letter\n description: a letter\n rule: single glyph",
|
|
`Auto-type: "hypergraph". `,
|
|
"Return JSON only, no commentary.",
|
|
`"name": "<exact extracted item text>"`,
|
|
`"source_chunk_ids": ["<source chunk id>", ...]`,
|
|
} {
|
|
if !strings.Contains(node, want) {
|
|
t.Errorf("node prompt missing %q\n---\n%s", want, node)
|
|
}
|
|
}
|
|
if strings.Contains(node, "Items must be unique.") {
|
|
t.Errorf("hypergraph (not set) must not demand uniqueness")
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"## Relation Extraction Rules:\nOnly stated links.",
|
|
"## Time Rules:\nAs of ",
|
|
"## Relation Description:\nLetter links.",
|
|
"## Relation Fields:\n- type: linked\n description: a link",
|
|
"## Known Entities:\n{known_nodes}",
|
|
"Only create relations between entities listed in 'Known Entities'.",
|
|
`"source": "<known entity name>"`,
|
|
} {
|
|
if !strings.Contains(edge, want) {
|
|
t.Errorf("edge prompt missing %q\n---\n%s", want, edge)
|
|
}
|
|
}
|
|
if strings.Contains(edge, "{observation_time}") {
|
|
t.Errorf("observation_time placeholder was not substituted")
|
|
}
|
|
}
|
|
|
|
func TestHypergraphPromptsSetUniquenessAndNoRelations(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"compile_type": "set",
|
|
"entity": map[string]any{"fields": []any{map[string]any{"type": "letter"}}},
|
|
}
|
|
node, edge := HypergraphPrompts(cfg, "en")
|
|
if !strings.Contains(node, "Items must be unique. ") {
|
|
t.Errorf("set kind must demand uniqueness:\n%s", node)
|
|
}
|
|
if !strings.Contains(node, `Auto-type: "set". `) {
|
|
t.Errorf("set Auto-type missing:\n%s", node)
|
|
}
|
|
if edge != "" {
|
|
t.Errorf("config without relations must skip the edge stage, got:\n%s", edge)
|
|
}
|
|
if got := InferType(cfg); got != TypeSet {
|
|
t.Errorf("InferType = %q, want set", got)
|
|
}
|
|
// "graph" / "knowledge_graph" aliases resolve to hypergraph.
|
|
for _, alias := range []string{"graph", "knowledge_graph"} {
|
|
if got := InferType(map[string]any{"kind": alias}); got != TypeHypergraph {
|
|
t.Errorf("InferType(kind=%q) = %q, want hypergraph", alias, got)
|
|
}
|
|
}
|
|
// Arbitrary kinds (timeline, page_index) are returned verbatim — Python
|
|
// stamps them as the autotype/compile_kwd.
|
|
if got := InferType(map[string]any{"kind": "timeline"}); got != Type("timeline") {
|
|
t.Errorf("InferType(kind=timeline) = %q, want verbatim timeline", got)
|
|
}
|
|
// An unknown compile_type is NOT a compile kind and falls through to kind.
|
|
if got := InferType(map[string]any{"compile_type": "timeline"}); got != TypeList {
|
|
t.Errorf("InferType(compile_type=timeline) = %q, want list (falls through)", got)
|
|
}
|
|
if got := InferType(map[string]any{}); got != TypeList {
|
|
t.Errorf("InferType(empty) = %q, want list", got)
|
|
}
|
|
}
|
|
|
|
func TestFillKnownNodes(t *testing.T) {
|
|
tmpl := "## Known Entities:\n{known_nodes}"
|
|
if got := fillKnownNodes(tmpl, nil); !strings.Contains(got, "(none)") {
|
|
t.Fatalf("empty known list must render (none): %q", got)
|
|
}
|
|
got := fillKnownNodes(tmpl, []string{"Alpha", "Beta"})
|
|
if !strings.Contains(got, "- Alpha\n- Beta") {
|
|
t.Fatalf("known list not rendered: %q", got)
|
|
}
|
|
}
|
|
|
|
// ---- payload helpers ----
|
|
|
|
func TestPayloadChunkIDs(t *testing.T) {
|
|
payload := map[string]any{"source_chunk_ids": []any{"c1", "nope", "c1"}}
|
|
got := payloadChunkIDs(payload, []string{"c1", "c2"})
|
|
if len(got) != 1 || got[0] != "c1" {
|
|
t.Fatalf("filter+dedupe failed: %v", got)
|
|
}
|
|
// Fallback: none of the model's ids belong to the batch -> all batch ids.
|
|
payload = map[string]any{"source_chunk_ids": []any{"nope"}}
|
|
got = payloadChunkIDs(payload, []string{"c1", "c2"})
|
|
if len(got) != 2 {
|
|
t.Fatalf("fallback to batch ids failed: %v", got)
|
|
}
|
|
// A bare string is accepted (mirrors Python).
|
|
payload = map[string]any{"source_chunk_ids": "c2"}
|
|
got = payloadChunkIDs(payload, []string{"c1", "c2"})
|
|
if len(got) != 1 || got[0] != "c2" {
|
|
t.Fatalf("string form failed: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPayloadDescriptionSortedAndFlattened(t *testing.T) {
|
|
payload := map[string]any{
|
|
"name": "Beta",
|
|
"type": "letter",
|
|
"source_chunk_ids": []any{"c1", "c2"},
|
|
}
|
|
got := payloadDescription(payload)
|
|
// Keys are sorted for determinism: name < source_chunk_ids < type.
|
|
if got != "Beta c1 c2 letter" {
|
|
t.Fatalf("payloadDescription = %q, want %q", got, "Beta c1 c2 letter")
|
|
}
|
|
}
|
|
|
|
// ---- Run: extraction + LLM dedup + graph ----
|
|
|
|
func TestStructureRunGraphKind(t *testing.T) {
|
|
deps := common.Deps{Chat: &graphChat{}, Embed: hashEmbedder{dim: 16}, TenantID: "t1", DatasetID: "d1"}
|
|
param := testParam()
|
|
inputs := common.Inputs{
|
|
DocID: "doc1",
|
|
Chunks: []common.Chunk{
|
|
{ID: "c1", Text: "Alpha is a Beta."},
|
|
{ID: "c2", Text: "Beta related to Gamma."},
|
|
},
|
|
VariantSpecific: map[string]any{"parser_config": graphParserConfig()},
|
|
}
|
|
|
|
out, err := Run(context.Background(), deps, param, inputs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
var entities, relations, graphs int
|
|
var betaProduct *common.Product
|
|
for i, p := range out.Products {
|
|
switch p.Meta["kind"] {
|
|
case "entity":
|
|
entities++
|
|
if p.Meta["name"] == "Beta" {
|
|
betaProduct = &out.Products[i]
|
|
}
|
|
if p.Meta["entity_type"] != "letter" {
|
|
t.Errorf("entity %v missing entity_type stamp", p.Meta["name"])
|
|
}
|
|
if p.Meta["compile_kwd"] != "hypergraph" {
|
|
t.Errorf("entity row compile_kwd = %v, want hypergraph", p.Meta["compile_kwd"])
|
|
}
|
|
case "relation":
|
|
relations++
|
|
case "graph":
|
|
graphs++
|
|
}
|
|
}
|
|
if entities != 3 || relations != 2 || graphs != 1 {
|
|
t.Fatalf("products = %d entities + %d relations + %d graph, want 3+2+1", entities, relations, graphs)
|
|
}
|
|
if out.DuplicatesDropped != 1 {
|
|
t.Fatalf("DuplicatesDropped = %d, want 1 (the cross-chunk Beta)", out.DuplicatesDropped)
|
|
}
|
|
if betaProduct == nil {
|
|
t.Fatal("no Beta entity product")
|
|
}
|
|
ids := metaStrings(betaProduct.Meta, "source_chunk_ids")
|
|
if len(ids) != 2 {
|
|
t.Fatalf("Beta provenance = %v, want {c1,c2} unioned through the merge", ids)
|
|
}
|
|
// The merged entity keeps the LLM-merged payload content (parseable JSON).
|
|
if parsePayload(betaProduct.Content) == nil {
|
|
t.Fatalf("entity content is not payload JSON: %q", betaProduct.Content)
|
|
}
|
|
|
|
// Graph summary mirrors Python's {entities, relations} shape.
|
|
g := parsePayload(graphContentOf(out))
|
|
if g == nil {
|
|
t.Fatal("graph product missing/unparseable")
|
|
}
|
|
gEnts, _ := g["entities"].([]any)
|
|
gRels, _ := g["relations"].([]any)
|
|
if len(gEnts) != 3 || len(gRels) != 2 {
|
|
t.Fatalf("graph = %d entities + %d relations, want 3+2", len(gEnts), len(gRels))
|
|
}
|
|
}
|
|
|
|
func TestStructureListKindSkipsRelations(t *testing.T) {
|
|
deps := common.Deps{Chat: &graphChat{}, Embed: hashEmbedder{dim: 16}, TenantID: "t1", DatasetID: "d1"}
|
|
param := testParam()
|
|
inputs := common.Inputs{
|
|
DocID: "doc1",
|
|
Chunks: []common.Chunk{{ID: "c1", Text: "Alpha is a Beta."}},
|
|
// No parser_config: InferType yields "list", so no edge stage runs.
|
|
}
|
|
out, err := Run(context.Background(), deps, param, inputs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
var relations int
|
|
for _, p := range out.Products {
|
|
if p.Meta["kind"] == "relation" {
|
|
relations++
|
|
}
|
|
if p.Meta["compile_kwd"] != "list" {
|
|
t.Errorf("compile_kwd = %v, want list", p.Meta["compile_kwd"])
|
|
}
|
|
}
|
|
if relations != 0 {
|
|
t.Fatalf("list kind must not extract relations, got %d", relations)
|
|
}
|
|
}
|
|
|
|
func TestStructureAliasRewrite(t *testing.T) {
|
|
// constEmbedder makes every pair a judge candidate; the mock judge merges
|
|
// Al into Alpha (the known alias pair), so the relation Al→Gamma must be
|
|
// rewritten to Alpha→Gamma by the alias pass.
|
|
deps := common.Deps{Chat: &graphChat{}, Embed: constEmbedder{dim: 4}, TenantID: "t1", DatasetID: "d1"}
|
|
param := testParam()
|
|
inputs := common.Inputs{
|
|
DocID: "doc1",
|
|
Chunks: []common.Chunk{
|
|
{ID: "c1", Text: "Alpha is a Beta."},
|
|
{ID: "c2", Text: "Al partners with Gamma."},
|
|
},
|
|
VariantSpecific: map[string]any{"parser_config": graphParserConfig()},
|
|
}
|
|
out, err := Run(context.Background(), deps, param, inputs)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
var sawAlEntity, sawRewritten bool
|
|
for _, p := range out.Products {
|
|
if p.Meta["kind"] == "entity" && p.Meta["name"] == "Al" {
|
|
sawAlEntity = true
|
|
}
|
|
if p.Meta["kind"] == "relation" {
|
|
payload := parsePayload(p.Content)
|
|
if payload == nil {
|
|
t.Fatalf("relation content not payload JSON: %q", p.Content)
|
|
}
|
|
if relationEndpoint(payload, "", "source") == "Alpha" && relationEndpoint(payload, "", "target") == "Gamma" {
|
|
sawRewritten = true
|
|
if p.Meta["from"] != "Alpha" || p.Meta["to"] != "Gamma" {
|
|
t.Errorf("rewritten relation meta from/to = %v/%v", p.Meta["from"], p.Meta["to"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if sawAlEntity {
|
|
t.Errorf("Al entity should have been merged into Alpha")
|
|
}
|
|
if !sawRewritten {
|
|
t.Errorf("relation Al→Gamma was not rewritten to Alpha→Gamma")
|
|
}
|
|
}
|
|
|
|
// ---- merge unit tests ----
|
|
|
|
func TestLLMMergeDeciderContracts(t *testing.T) {
|
|
chat := &graphChat{}
|
|
d := NewLLMMergeDecider(chat, "llm1", hashEmbedder{dim: 8}, 0.99)
|
|
|
|
// Below threshold: no LLM call, keep both.
|
|
before := chat.mergeCalls
|
|
dec, _, err := d.Decide(context.Background(), common.Product{}, common.Product{}, 0.5)
|
|
if err != nil || dec != DecisionKeepBoth || chat.mergeCalls != before {
|
|
t.Fatalf("below-threshold pair must be kept without an LLM call: dec=%v err=%v", dec, err)
|
|
}
|
|
|
|
existing := common.Product{
|
|
ID: "row-alpha",
|
|
Content: payloadJSON(map[string]any{"type": "letter", "name": "Alpha", "description": "the letter Alpha"}),
|
|
Meta: map[string]any{"kind": "entity", "name": "Alpha", "source_chunk_ids": []string{"c1"}},
|
|
}
|
|
incoming := common.Product{
|
|
ID: "row-alpha-2",
|
|
Content: payloadJSON(map[string]any{"type": "letter", "name": "Al", "description": "short for Alpha"}),
|
|
Meta: map[string]any{"kind": "entity", "name": "Al", "source_chunk_ids": []string{"c2"}},
|
|
}
|
|
dec, merged, err := d.Decide(context.Background(), existing, incoming, 1.0)
|
|
if err != nil {
|
|
t.Fatalf("Decide: %v", err)
|
|
}
|
|
if dec != DecisionMerge {
|
|
t.Fatalf("alias pair must merge: dec=%v", dec)
|
|
}
|
|
if merged.ID != "row-alpha" {
|
|
t.Errorf("merged row must preserve the existing id, got %q", merged.ID)
|
|
}
|
|
if got := entityName(parsePayload(merged.Content)); got != "Alpha" {
|
|
t.Errorf("merged canonical name = %q, want Alpha", got)
|
|
}
|
|
if ids := metaStrings(merged.Meta, "source_chunk_ids"); len(ids) != 2 {
|
|
t.Errorf("merged provenance = %v, want {c1,c2}", ids)
|
|
}
|
|
if aliases := d.Aliases(); aliases["Al"] != "Alpha" {
|
|
t.Errorf("alias map = %v, want Al→Alpha", aliases)
|
|
}
|
|
}
|
|
|
|
func TestApplyMergeInvariants(t *testing.T) {
|
|
existing := common.Product{
|
|
Content: payloadJSON(map[string]any{"type": "linked", "source": "Alpha", "target": "Beta"}),
|
|
Meta: map[string]any{"kind": "relation"},
|
|
}
|
|
merged := map[string]any{"type": "linked", "source": "WRONG", "target": "ALSO_WRONG", "description": "richer"}
|
|
out := applyMergeInvariants(existing, merged)
|
|
if out["source"] != "Alpha" || out["target"] != "Beta" {
|
|
t.Fatalf("relation endpoints must be pinned to the existing payload: %v", out)
|
|
}
|
|
if out["description"] != "richer" {
|
|
t.Fatalf("non-endpoint fields must survive the merge: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestResolveAliasToleratesCycles(t *testing.T) {
|
|
aliases := map[string]string{"A": "B", "B": "A"}
|
|
if got := resolveAlias("A", aliases); got != "A" && got != "B" {
|
|
t.Fatalf("cycle must terminate: %q", got)
|
|
}
|
|
if got := resolveAlias("x", map[string]string{"x": "y", "y": "z"}); got != "z" {
|
|
t.Fatalf("chain resolution failed: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCosineDecider(t *testing.T) {
|
|
d := CosineDecider{Threshold: 0.99}
|
|
got, _, err := d.Decide(context.Background(), common.Product{}, common.Product{}, 1.0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != DecisionDropIncoming {
|
|
t.Fatalf("expected drop at 1.0, got %v", got)
|
|
}
|
|
got, _, _ = d.Decide(context.Background(), common.Product{}, common.Product{}, 0.5)
|
|
if got != DecisionKeepBoth {
|
|
t.Fatalf("expected keep at 0.5, got %v", got)
|
|
}
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func graphContentOf(out common.Outputs) string {
|
|
for _, p := range out.Products {
|
|
if p.Meta["kind"] == "graph" {
|
|
return p.Content
|
|
}
|
|
}
|
|
return ""
|
|
}
|