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
134 lines
6.1 KiB
Go
134 lines
6.1 KiB
Go
package chatpipeline
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
func graphCoverageResponse(t *testing.T, graph *types.GraphData) *types.ChatResponse {
|
|
t.Helper()
|
|
items := make([]map[string]any, 0, len(graph.Node)+len(graph.Relation))
|
|
for _, node := range graph.Node {
|
|
items = append(items, map[string]any{"entity": node.Name, "entity_attributes": node.Attributes})
|
|
}
|
|
for _, relation := range graph.Relation {
|
|
items = append(items, map[string]any{"entity1": relation.Node1, "entity2": relation.Node2, "relation": relation.Type, "evidence": relation.Evidence})
|
|
}
|
|
raw, err := json.Marshal(items)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &types.ChatResponse{Content: string(raw), FinishReason: "stop"}
|
|
}
|
|
|
|
func TestGraphStorageCoverageSavedProbeRequiresReview(t *testing.T) {
|
|
raw, err := os.ReadFile("testdata/graph_storage_coverage_add45b26.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fixture struct {
|
|
SourceSHA256 string `json:"source_sha256"`
|
|
Source string `json:"source_content"`
|
|
Graph *types.GraphData `json:"graph"`
|
|
}
|
|
if err := json.Unmarshal(raw, &fixture); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256([]byte(fixture.Source))
|
|
if hex.EncodeToString(digest[:]) != fixture.SourceSHA256 || len(fixture.Graph.Relation) != 10 {
|
|
t.Fatal("saved probe source or returned relations changed")
|
|
}
|
|
model := &extractionResponseChat{response: graphCoverageResponse(t, fixture.Graph)}
|
|
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
|
graph, err := extractor.Extract(context.Background(), fixture.Source)
|
|
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "置于丿 L 童不易触及处") || model.calls != 2 {
|
|
t.Fatalf("saved omission must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
|
}
|
|
if !strings.Contains(model.messages[len(model.messages)-1].Content, "置于丿 L 童不易触及处") {
|
|
t.Fatal("correction lost the original OCR clause")
|
|
}
|
|
}
|
|
|
|
func TestGraphStorageCoverageRequiresCompleteConstraint(t *testing.T) {
|
|
const clause = "置于丿 L 童不易触及处"
|
|
const source = "本品不得口服," + clause + "。"
|
|
for _, tc := range []struct {
|
|
name, target, label, evidence string
|
|
attribute bool
|
|
}{
|
|
{name: "omitted relation"},
|
|
{name: "attribute alone", attribute: true},
|
|
{name: "normalized OCR target", target: "置于儿童不易触及处", label: "约束", evidence: clause},
|
|
{name: "restriction removed from target", target: "置于丿 L 童触及处", label: "约束", evidence: clause},
|
|
{name: "evidence alone", target: "储存要求", label: "约束", evidence: clause},
|
|
{name: "unrelated evidence", target: clause, label: "约束", evidence: "本品不得口服"},
|
|
{name: "wrong relationship", target: clause, label: "关联", evidence: clause},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
|
if tc.target != "" {
|
|
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence})
|
|
}
|
|
if tc.attribute {
|
|
input.Node = []*types.GraphNode{{Name: "本品", Attributes: []string{clause}}}
|
|
}
|
|
model := &extractionResponseChat{response: graphCoverageResponse(t, input)}
|
|
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
|
graph, err := extractor.Extract(context.Background(), source)
|
|
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || model.calls != 2 {
|
|
t.Fatalf("missing constraint passed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGraphStorageCoverageCorrectionPreservesOCR(t *testing.T) {
|
|
const clause = "置于丿 L 童不易触及处"
|
|
const source = "本品不得口服," + clause + "。"
|
|
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
|
incomplete := graphCoverageResponse(t, input)
|
|
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: clause, Type: "约束", Evidence: clause})
|
|
model := &extractionResponseChat{responses: []*types.ChatResponse{incomplete, graphCoverageResponse(t, input)}}
|
|
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
|
graph, err := extractor.Extract(context.Background(), source)
|
|
if err != nil || graph == nil || len(graph.Relation) != 2 || model.calls != 2 {
|
|
t.Fatalf("source-backed correction failed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
|
}
|
|
if graph.Relation[1].Node2 != clause || graph.Relation[1].Evidence != clause {
|
|
t.Fatalf("OCR was changed: %+v", graph.Relation[1])
|
|
}
|
|
}
|
|
|
|
func TestGraphStorageCoverageScope(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
source string
|
|
wantReview bool
|
|
}{
|
|
{"请放置于儿童不易触及之处。", true},
|
|
{"〔 储存方法 〗 置于丿 L 童不易触及处。", true},
|
|
{"放在儿童不易触及处。", true},
|
|
{"教材介绍储存要求。", false},
|
|
{"作者讨论“置于儿童不易触及处”这句话。", false},
|
|
{"他认为置于儿童不易触及处能够保证安全。", false},
|
|
} {
|
|
err := validateGraphStorageCoverage(&types.GraphData{}, tc.source)
|
|
if errors.Is(err, ErrGraphNeedsReview) != tc.wantReview {
|
|
t.Errorf("source=%q, error=%v, want review=%v", tc.source, err, tc.wantReview)
|
|
}
|
|
}
|
|
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"储存"}]`, FinishReason: "stop"}}
|
|
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
|
graph, err := extractor.Extract(context.Background(), "置于丿 L 童不易触及处")
|
|
if err != nil || graph == nil || len(graph.Node) != 1 || model.calls != 1 {
|
|
t.Fatalf("query extraction should not require persisted-graph coverage: graph=%+v, err=%v", graph, err)
|
|
}
|
|
}
|