Compare commits
6 Commits
60682e977e
...
6f7d525805
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f7d525805 | |||
| 26595e0105 | |||
| 94286c70b6 | |||
| 346eebe815 | |||
| 9431719db6 | |||
| 0c68d3f727 |
11
cmd/notarius/main.go
Normal file
11
cmd/notarius/main.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.maximumdirect.net/eric/notarius
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
30
internal/cli/run.go
Normal file
30
internal/cli/run.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usage = "Usage:\n notarius help\n"
|
||||||
|
|
||||||
|
// Run executes the command-line interface and returns a process exit code.
|
||||||
|
func Run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
writeUsage(stdout)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
writeUsage(stdout)
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0])
|
||||||
|
writeUsage(stderr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUsage(w io.Writer) {
|
||||||
|
fmt.Fprint(w, usage)
|
||||||
|
}
|
||||||
75
internal/cli/run_test.go
Normal file
75
internal/cli/run_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Run(nil, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Run() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
if stdout.String() != usage {
|
||||||
|
t.Fatalf("stdout = %q, want %q", stdout.String(), usage)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelpArgsWriteUsageToStdout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{name: "help", args: []string{"help"}},
|
||||||
|
{name: "long help flag", args: []string{"--help"}},
|
||||||
|
{name: "short help flag", args: []string{"-h"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Run(tt.args, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Run() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
if stdout.String() != usage {
|
||||||
|
t.Fatalf("stdout = %q, want %q", stdout.String(), usage)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Run([]string{"extract"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != 2 {
|
||||||
|
t.Fatalf("Run() code = %d, want 2", code)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
gotStderr := stderr.String()
|
||||||
|
if !strings.Contains(gotStderr, "notarius: unknown command \"extract\"") {
|
||||||
|
t.Fatalf("stderr = %q, want unknown command error", gotStderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotStderr, usage) {
|
||||||
|
t.Fatalf("stderr = %q, want usage", gotStderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/core/artifacts/artifacts.go
Normal file
68
internal/core/artifacts/artifacts.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Candidate struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ExtractorKey string `json:"extractor_key"`
|
||||||
|
ArtifactType string `json:"artifact_type"`
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Artifact struct {
|
||||||
|
ExtractorKey string `json:"extractor_key"`
|
||||||
|
ArtifactType string `json:"artifact_type"`
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RejectedArtifact struct {
|
||||||
|
Candidate Candidate `json:"candidate"`
|
||||||
|
ValidatorName string `json:"validator_name"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunManifest struct {
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
|
InputAdapter string `json:"input_adapter,omitempty"`
|
||||||
|
SourceDigests []string `json:"source_digests,omitempty"`
|
||||||
|
Extractors []string `json:"extractors,omitempty"`
|
||||||
|
SchemaVersion string `json:"schema_version,omitempty"`
|
||||||
|
ValidationStatus string `json:"validation_status,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ArtifactFromCandidate(candidate Candidate) Artifact {
|
||||||
|
return Artifact{
|
||||||
|
ExtractorKey: candidate.ExtractorKey,
|
||||||
|
ArtifactType: candidate.ArtifactType,
|
||||||
|
SchemaVersion: candidate.SchemaVersion,
|
||||||
|
Payload: append(json.RawMessage(nil), candidate.Payload...),
|
||||||
|
SourceRefs: append([]source.SourceRef(nil), candidate.SourceRefs...),
|
||||||
|
Metadata: copyMetadata(candidate.Metadata),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMetadata(metadata map[string]any) map[string]any {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
copied := make(map[string]any, len(metadata))
|
||||||
|
for key, value := range metadata {
|
||||||
|
copied[key] = value
|
||||||
|
}
|
||||||
|
return copied
|
||||||
|
}
|
||||||
134
internal/core/artifacts/artifacts_test.go
Normal file
134
internal/core/artifacts/artifacts_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArtifactFromCandidatePreservesCandidateFields(t *testing.T) {
|
||||||
|
candidate := Candidate{
|
||||||
|
Index: 7,
|
||||||
|
ExtractorKey: "generic-extractor",
|
||||||
|
ArtifactType: "generic-artifact",
|
||||||
|
SchemaVersion: "v1",
|
||||||
|
Payload: json.RawMessage(`{"name":"example"}`),
|
||||||
|
SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u2"},
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"confidence": 0.75,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact := ArtifactFromCandidate(candidate)
|
||||||
|
|
||||||
|
if artifact.ExtractorKey != candidate.ExtractorKey {
|
||||||
|
t.Fatalf("ExtractorKey = %q, want %q", artifact.ExtractorKey, candidate.ExtractorKey)
|
||||||
|
}
|
||||||
|
if artifact.ArtifactType != candidate.ArtifactType {
|
||||||
|
t.Fatalf("ArtifactType = %q, want %q", artifact.ArtifactType, candidate.ArtifactType)
|
||||||
|
}
|
||||||
|
if artifact.SchemaVersion != candidate.SchemaVersion {
|
||||||
|
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, candidate.SchemaVersion)
|
||||||
|
}
|
||||||
|
if string(artifact.Payload) != string(candidate.Payload) {
|
||||||
|
t.Fatalf("Payload = %s, want %s", artifact.Payload, candidate.Payload)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(artifact.SourceRefs, candidate.SourceRefs) {
|
||||||
|
t.Fatalf("SourceRefs = %#v, want %#v", artifact.SourceRefs, candidate.SourceRefs)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(artifact.Metadata, candidate.Metadata) {
|
||||||
|
t.Fatalf("Metadata = %#v, want %#v", artifact.Metadata, candidate.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.Payload[0] = '['
|
||||||
|
candidate.SourceRefs[0].StartUnitID = "changed"
|
||||||
|
candidate.Metadata["confidence"] = 0.5
|
||||||
|
|
||||||
|
if string(artifact.Payload) != `{"name":"example"}` {
|
||||||
|
t.Fatalf("Payload changed after candidate mutation: %s", artifact.Payload)
|
||||||
|
}
|
||||||
|
if artifact.SourceRefs[0].StartUnitID != "u1" {
|
||||||
|
t.Fatalf("SourceRefs changed after candidate mutation: %#v", artifact.SourceRefs)
|
||||||
|
}
|
||||||
|
if artifact.Metadata["confidence"] != 0.75 {
|
||||||
|
t.Fatalf("Metadata changed after candidate mutation: %#v", artifact.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONMarshalUsesExpectedFieldNames(t *testing.T) {
|
||||||
|
candidate := Candidate{
|
||||||
|
Index: 1,
|
||||||
|
ExtractorKey: "generic-extractor",
|
||||||
|
ArtifactType: "generic-artifact",
|
||||||
|
SchemaVersion: "v1",
|
||||||
|
Payload: json.RawMessage(`{"value":true}`),
|
||||||
|
SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"},
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"reviewed": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rejected := RejectedArtifact{
|
||||||
|
Candidate: candidate,
|
||||||
|
ValidatorName: "generic-validator",
|
||||||
|
ReasonCode: "invalid",
|
||||||
|
Message: "candidate was not accepted",
|
||||||
|
}
|
||||||
|
|
||||||
|
gotJSON, err := json.Marshal(rejected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(gotJSON, &got); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHasKeys(t, got, "candidate", "validator_name", "reason_code", "message")
|
||||||
|
|
||||||
|
gotCandidate, ok := got["candidate"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("candidate = %#v, want object", got["candidate"])
|
||||||
|
}
|
||||||
|
assertHasKeys(t, gotCandidate, "index", "extractor_key", "artifact_type", "schema_version", "payload", "source_refs", "metadata")
|
||||||
|
|
||||||
|
gotRefs, ok := gotCandidate["source_refs"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("source_refs = %#v, want array", gotCandidate["source_refs"])
|
||||||
|
}
|
||||||
|
if len(gotRefs) != 1 {
|
||||||
|
t.Fatalf("len(source_refs) = %d, want 1", len(gotRefs))
|
||||||
|
}
|
||||||
|
gotRef, ok := gotRefs[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("source_refs[0] = %#v, want object", gotRefs[0])
|
||||||
|
}
|
||||||
|
assertHasKeys(t, gotRef, "source_id", "start_unit_id", "end_unit_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
gotJSON, err := json.Marshal(RunManifest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(gotJSON) != "{}" {
|
||||||
|
t.Fatalf("json.Marshal(RunManifest{}) = %s, want {}", gotJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHasKeys(t *testing.T, values map[string]any, keys ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := values[key]; !ok {
|
||||||
|
t.Fatalf("missing key %q in %#v", key, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/core/source/source.go
Normal file
23
internal/core/source/source.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
type SourceDocument struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Units []SourceUnit `json:"units"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceUnit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceRef struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
StartUnitID string `json:"start_unit_id"`
|
||||||
|
EndUnitID string `json:"end_unit_id"`
|
||||||
|
}
|
||||||
275
internal/core/source/source_test.go
Normal file
275
internal/core/source/source_test.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDocumentValid(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
|
||||||
|
if err := ValidateDocument(doc); err != nil {
|
||||||
|
t.Fatalf("ValidateDocument() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocumentNil(t *testing.T) {
|
||||||
|
err := ValidateDocument(nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDocument() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != "source document must not be nil" {
|
||||||
|
t.Fatalf("ValidateDocument() error = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocumentMissingFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*SourceDocument)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.ID = " \t" },
|
||||||
|
wantErr: "source document id must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kind",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Kind = "" },
|
||||||
|
wantErr: "source document kind must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "format",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Format = "\n" },
|
||||||
|
wantErr: "source document format must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "digest",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Digest = "" },
|
||||||
|
wantErr: "source document digest must not be empty",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
tt.mutate(doc)
|
||||||
|
|
||||||
|
err := ValidateDocument(doc)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDocument() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != tt.wantErr {
|
||||||
|
t.Fatalf("ValidateDocument() error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocumentEmptyUnits(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
doc.Units = nil
|
||||||
|
|
||||||
|
err := ValidateDocument(doc)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDocument() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != "source document units must not be empty" {
|
||||||
|
t.Fatalf("ValidateDocument() error = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocumentMissingUnitFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*SourceDocument)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Units[1].ID = "" },
|
||||||
|
wantErr: "source unit[1].id must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kind",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Units[1].Kind = " " },
|
||||||
|
wantErr: "source unit[1].kind must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text",
|
||||||
|
mutate: func(doc *SourceDocument) { doc.Units[1].Text = "\n\t" },
|
||||||
|
wantErr: "source unit[1].text must not be empty",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
tt.mutate(doc)
|
||||||
|
|
||||||
|
err := ValidateDocument(doc)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDocument() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != tt.wantErr {
|
||||||
|
t.Fatalf("ValidateDocument() error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocumentDuplicateUnitIDs(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
doc.Units[1].ID = " u1 "
|
||||||
|
|
||||||
|
err := ValidateDocument(doc)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDocument() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != "source unit id \"u1\" is duplicated" {
|
||||||
|
t.Fatalf("ValidateDocument() error = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRefValid(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
ref := SourceRef{
|
||||||
|
SourceID: "source-1",
|
||||||
|
StartUnitID: "u1",
|
||||||
|
EndUnitID: "u2",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateRef(doc, ref); err != nil {
|
||||||
|
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRefSourceIDMismatch(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
ref := SourceRef{
|
||||||
|
SourceID: "source-2",
|
||||||
|
StartUnitID: "u1",
|
||||||
|
EndUnitID: "u2",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateRef(doc, ref)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateRef() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != "source ref source_id \"source-2\" does not match document id \"source-1\"" {
|
||||||
|
t.Fatalf("ValidateRef() error = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRefMissingUnitIDs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref SourceRef
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing source id",
|
||||||
|
ref: SourceRef{StartUnitID: "u1", EndUnitID: "u2"},
|
||||||
|
wantErr: "source ref source_id must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing start id",
|
||||||
|
ref: SourceRef{SourceID: "source-1", EndUnitID: "u2"},
|
||||||
|
wantErr: "source ref start_unit_id must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing end id",
|
||||||
|
ref: SourceRef{SourceID: "source-1", StartUnitID: "u1"},
|
||||||
|
wantErr: "source ref end_unit_id must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown start id",
|
||||||
|
ref: SourceRef{SourceID: "source-1", StartUnitID: "u9", EndUnitID: "u2"},
|
||||||
|
wantErr: "source ref start_unit_id \"u9\" was not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown end id",
|
||||||
|
ref: SourceRef{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u9"},
|
||||||
|
wantErr: "source ref end_unit_id \"u9\" was not found",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateRef(validDocument(), tt.ref)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateRef() error = nil, want error")
|
||||||
|
}
|
||||||
|
if err.Error() != tt.wantErr {
|
||||||
|
t.Fatalf("ValidateRef() error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRefReversedUnitOrder(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
ref := SourceRef{
|
||||||
|
SourceID: "source-1",
|
||||||
|
StartUnitID: "u2",
|
||||||
|
EndUnitID: "u1",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateRef(doc, ref)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateRef() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "appears after") {
|
||||||
|
t.Fatalf("ValidateRef() error = %q, want reversed order error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnitIndex(t *testing.T) {
|
||||||
|
doc := validDocument()
|
||||||
|
|
||||||
|
index, ok := UnitIndex(doc, "u2")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("UnitIndex() ok = false, want true")
|
||||||
|
}
|
||||||
|
if index != 1 {
|
||||||
|
t.Fatalf("UnitIndex() index = %d, want 1", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
index, ok = UnitIndex(doc, "u9")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("UnitIndex() ok = true, want false")
|
||||||
|
}
|
||||||
|
if index != 0 {
|
||||||
|
t.Fatalf("UnitIndex() index = %d, want 0", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDocument() *SourceDocument {
|
||||||
|
return &SourceDocument{
|
||||||
|
ID: "source-1",
|
||||||
|
Kind: "document",
|
||||||
|
Format: "text/plain",
|
||||||
|
Digest: "sha256:abc123",
|
||||||
|
Units: []SourceUnit{
|
||||||
|
{
|
||||||
|
ID: "u1",
|
||||||
|
Kind: "paragraph",
|
||||||
|
Text: "First unit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "u2",
|
||||||
|
Kind: "paragraph",
|
||||||
|
Text: "Second unit.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/core/source/validation.go
Normal file
95
internal/core/source/validation.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateDocument(doc *SourceDocument) error {
|
||||||
|
if doc == nil {
|
||||||
|
return fmt.Errorf("source document must not be nil")
|
||||||
|
}
|
||||||
|
if isBlank(doc.ID) {
|
||||||
|
return fmt.Errorf("source document id must not be empty")
|
||||||
|
}
|
||||||
|
if isBlank(doc.Kind) {
|
||||||
|
return fmt.Errorf("source document kind must not be empty")
|
||||||
|
}
|
||||||
|
if isBlank(doc.Format) {
|
||||||
|
return fmt.Errorf("source document format must not be empty")
|
||||||
|
}
|
||||||
|
if isBlank(doc.Digest) {
|
||||||
|
return fmt.Errorf("source document digest must not be empty")
|
||||||
|
}
|
||||||
|
if len(doc.Units) == 0 {
|
||||||
|
return fmt.Errorf("source document units must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
seenUnitIDs := make(map[string]struct{}, len(doc.Units))
|
||||||
|
for i, unit := range doc.Units {
|
||||||
|
unitID := strings.TrimSpace(unit.ID)
|
||||||
|
if unitID == "" {
|
||||||
|
return fmt.Errorf("source unit[%d].id must not be empty", i)
|
||||||
|
}
|
||||||
|
if isBlank(unit.Kind) {
|
||||||
|
return fmt.Errorf("source unit[%d].kind must not be empty", i)
|
||||||
|
}
|
||||||
|
if isBlank(unit.Text) {
|
||||||
|
return fmt.Errorf("source unit[%d].text must not be empty", i)
|
||||||
|
}
|
||||||
|
if _, ok := seenUnitIDs[unitID]; ok {
|
||||||
|
return fmt.Errorf("source unit id %q is duplicated", unitID)
|
||||||
|
}
|
||||||
|
seenUnitIDs[unitID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateRef(doc *SourceDocument, ref SourceRef) error {
|
||||||
|
if doc == nil {
|
||||||
|
return fmt.Errorf("source document must not be nil")
|
||||||
|
}
|
||||||
|
if isBlank(ref.SourceID) {
|
||||||
|
return fmt.Errorf("source ref source_id must not be empty")
|
||||||
|
}
|
||||||
|
if isBlank(ref.StartUnitID) {
|
||||||
|
return fmt.Errorf("source ref start_unit_id must not be empty")
|
||||||
|
}
|
||||||
|
if isBlank(ref.EndUnitID) {
|
||||||
|
return fmt.Errorf("source ref end_unit_id must not be empty")
|
||||||
|
}
|
||||||
|
if ref.SourceID != doc.ID {
|
||||||
|
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, doc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex, ok := UnitIndex(doc, ref.StartUnitID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("source ref start_unit_id %q was not found", ref.StartUnitID)
|
||||||
|
}
|
||||||
|
endIndex, ok := UnitIndex(doc, ref.EndUnitID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("source ref end_unit_id %q was not found", ref.EndUnitID)
|
||||||
|
}
|
||||||
|
if startIndex > endIndex {
|
||||||
|
return fmt.Errorf("source ref start_unit_id %q appears after end_unit_id %q", ref.StartUnitID, ref.EndUnitID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnitIndex(doc *SourceDocument, unitID string) (int, bool) {
|
||||||
|
if doc == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for i, unit := range doc.Units {
|
||||||
|
if unit.ID == unitID {
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlank(value string) bool {
|
||||||
|
return strings.TrimSpace(value) == ""
|
||||||
|
}
|
||||||
150
internal/framework/contracts/composition_test.go
Normal file
150
internal/framework/contracts/composition_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package contracts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ contracts.InputAdapter = compositionAdapter{}
|
||||||
|
var _ contracts.Extractor = compositionExtractor{}
|
||||||
|
var _ contracts.Validator = compositionValidator{}
|
||||||
|
|
||||||
|
func TestContractsComposeAcrossPackages(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
adapter := compositionAdapter{}
|
||||||
|
extractor := compositionExtractor{}
|
||||||
|
validator := compositionValidator{}
|
||||||
|
|
||||||
|
doc, err := adapter.Parse(ctx, contracts.ParseRequest{SourceID: "source-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if err := source.ValidateDocument(doc); err != nil {
|
||||||
|
t.Fatalf("ValidateDocument() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extraction, err := extractor.Extract(ctx, contracts.ExtractionRequest{Source: doc})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if len(extraction.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(extraction.Candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := extraction.Candidates[0]
|
||||||
|
for _, ref := range candidate.SourceRefs {
|
||||||
|
if err := source.ValidateRef(doc, ref); err != nil {
|
||||||
|
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err := validator.Validate(ctx, contracts.ValidationRequest{
|
||||||
|
Source: doc,
|
||||||
|
Candidates: extraction.Candidates,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if len(validation.Decisions) != 1 {
|
||||||
|
t.Fatalf("len(Decisions) = %d, want 1", len(validation.Decisions))
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := validation.Decisions[0]
|
||||||
|
if !decision.Approved {
|
||||||
|
t.Fatal("Approved = false, want true")
|
||||||
|
}
|
||||||
|
if decision.CandidateIndex != candidate.Index {
|
||||||
|
t.Fatalf("CandidateIndex = %d, want %d", decision.CandidateIndex, candidate.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type compositionAdapter struct{}
|
||||||
|
|
||||||
|
func (adapter compositionAdapter) Key() string {
|
||||||
|
return "generic-input"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter compositionAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
||||||
|
return &source.SourceDocument{
|
||||||
|
ID: req.SourceID,
|
||||||
|
Kind: "document",
|
||||||
|
Format: "text/plain",
|
||||||
|
Digest: "sha256:abc123",
|
||||||
|
Units: []source.SourceUnit{
|
||||||
|
{ID: "u1", Kind: "unit", Text: "First source unit."},
|
||||||
|
{ID: "u2", Kind: "unit", Text: "Second source unit."},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type compositionExtractor struct{}
|
||||||
|
|
||||||
|
func (extractor compositionExtractor) Key() string {
|
||||||
|
return "generic-extractor"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor compositionExtractor) ArtifactType() string {
|
||||||
|
return "generic-artifact"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor compositionExtractor) SchemaVersion() string {
|
||||||
|
return "v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor compositionExtractor) Validators() []contracts.Validator {
|
||||||
|
return []contracts.Validator{compositionValidator{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor compositionExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
||||||
|
if req.Source == nil {
|
||||||
|
return contracts.ExtractionResult{}, errors.New("source document is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return contracts.ExtractionResult{
|
||||||
|
Candidates: []artifacts.Candidate{
|
||||||
|
{
|
||||||
|
Index: 0,
|
||||||
|
ExtractorKey: extractor.Key(),
|
||||||
|
ArtifactType: extractor.ArtifactType(),
|
||||||
|
SchemaVersion: extractor.SchemaVersion(),
|
||||||
|
Payload: json.RawMessage(`{"value":"example"}`),
|
||||||
|
SourceRefs: []source.SourceRef{
|
||||||
|
{
|
||||||
|
SourceID: req.Source.ID,
|
||||||
|
StartUnitID: req.Source.Units[0].ID,
|
||||||
|
EndUnitID: req.Source.Units[1].ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type compositionValidator struct{}
|
||||||
|
|
||||||
|
func (validator compositionValidator) Name() string {
|
||||||
|
return "generic-validator"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (validator compositionValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
||||||
|
decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates))
|
||||||
|
for _, candidate := range req.Candidates {
|
||||||
|
decisions = append(decisions, contracts.ValidationDecision{
|
||||||
|
CandidateIndex: candidate.Index,
|
||||||
|
Approved: true,
|
||||||
|
ReasonCode: "accepted",
|
||||||
|
Message: "candidate accepted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
ValidatorName: validator.Name(),
|
||||||
|
Decisions: decisions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
97
internal/framework/contracts/contracts.go
Normal file
97
internal/framework/contracts/contracts.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package contracts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LLMMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuredCompletionRequest struct {
|
||||||
|
StageName string `json:"stage_name"`
|
||||||
|
Messages []LLMMessage `json:"messages"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
ResponseSchemaName string `json:"response_schema_name,omitempty"`
|
||||||
|
ResponseSchema json.RawMessage `json:"response_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuredCompletionResponse struct {
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||||
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
|
TotalTokens int `json:"total_tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuredLLMClient interface {
|
||||||
|
CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParseRequest struct {
|
||||||
|
SourceID string `json:"source_id,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Raw []byte `json:"-"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputAdapter interface {
|
||||||
|
Key() string
|
||||||
|
Parse(ctx context.Context, req ParseRequest) (*source.SourceDocument, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractionRequest struct {
|
||||||
|
Source *source.SourceDocument `json:"-"`
|
||||||
|
LLMClient StructuredLLMClient `json:"-"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractionResult struct {
|
||||||
|
Candidates []artifacts.Candidate `json:"candidates,omitempty"`
|
||||||
|
Warnings []Warning `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Extractor interface {
|
||||||
|
Key() string
|
||||||
|
ArtifactType() string
|
||||||
|
SchemaVersion() string
|
||||||
|
Validators() []Validator
|
||||||
|
Extract(ctx context.Context, req ExtractionRequest) (ExtractionResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationRequest struct {
|
||||||
|
Source *source.SourceDocument `json:"-"`
|
||||||
|
Candidates []artifacts.Candidate `json:"candidates"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationDecision struct {
|
||||||
|
CandidateIndex int `json:"candidate_index"`
|
||||||
|
Approved bool `json:"approved"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationResult struct {
|
||||||
|
ValidatorName string `json:"validator_name"`
|
||||||
|
Decisions []ValidationDecision `json:"decisions"`
|
||||||
|
Warnings []Warning `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Validator interface {
|
||||||
|
Name() string
|
||||||
|
Validate(ctx context.Context, req ValidationRequest) (ValidationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Warning struct {
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
165
internal/framework/contracts/contracts_test.go
Normal file
165
internal/framework/contracts/contracts_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package contracts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ InputAdapter = fakeAdapter{}
|
||||||
|
var _ Extractor = fakeExtractor{}
|
||||||
|
var _ Validator = fakeValidator{}
|
||||||
|
var _ StructuredLLMClient = fakeLLMClient{}
|
||||||
|
|
||||||
|
func TestFakeExtractorReturnsCandidateAndValidator(t *testing.T) {
|
||||||
|
validator := fakeValidator{name: "generic-validator"}
|
||||||
|
extractor := fakeExtractor{
|
||||||
|
key: "generic-extractor",
|
||||||
|
artifactType: "generic-artifact",
|
||||||
|
schemaVersion: "v1",
|
||||||
|
validators: []Validator{validator},
|
||||||
|
}
|
||||||
|
doc := &source.SourceDocument{
|
||||||
|
ID: "source-1",
|
||||||
|
Kind: "document",
|
||||||
|
Format: "text/plain",
|
||||||
|
Digest: "sha256:abc123",
|
||||||
|
Units: []source.SourceUnit{
|
||||||
|
{ID: "u1", Kind: "section", Text: "Source text."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := extractor.Extract(context.Background(), ExtractionRequest{Source: doc})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if extractor.Key() != "generic-extractor" {
|
||||||
|
t.Fatalf("Key() = %q, want generic-extractor", extractor.Key())
|
||||||
|
}
|
||||||
|
if extractor.ArtifactType() != "generic-artifact" {
|
||||||
|
t.Fatalf("ArtifactType() = %q, want generic-artifact", extractor.ArtifactType())
|
||||||
|
}
|
||||||
|
if extractor.SchemaVersion() != "v1" {
|
||||||
|
t.Fatalf("SchemaVersion() = %q, want v1", extractor.SchemaVersion())
|
||||||
|
}
|
||||||
|
if len(extractor.Validators()) != 1 {
|
||||||
|
t.Fatalf("len(Validators()) = %d, want 1", len(extractor.Validators()))
|
||||||
|
}
|
||||||
|
if extractor.Validators()[0].Name() != "generic-validator" {
|
||||||
|
t.Fatalf("Validators()[0].Name() = %q, want generic-validator", extractor.Validators()[0].Name())
|
||||||
|
}
|
||||||
|
if len(result.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(result.Candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := result.Candidates[0]
|
||||||
|
if candidate.Index != 0 {
|
||||||
|
t.Fatalf("Candidate.Index = %d, want 0", candidate.Index)
|
||||||
|
}
|
||||||
|
if candidate.ExtractorKey != extractor.Key() {
|
||||||
|
t.Fatalf("Candidate.ExtractorKey = %q, want %q", candidate.ExtractorKey, extractor.Key())
|
||||||
|
}
|
||||||
|
if candidate.ArtifactType != extractor.ArtifactType() {
|
||||||
|
t.Fatalf("Candidate.ArtifactType = %q, want %q", candidate.ArtifactType, extractor.ArtifactType())
|
||||||
|
}
|
||||||
|
if candidate.SchemaVersion != extractor.SchemaVersion() {
|
||||||
|
t.Fatalf("Candidate.SchemaVersion = %q, want %q", candidate.SchemaVersion, extractor.SchemaVersion())
|
||||||
|
}
|
||||||
|
if string(candidate.Payload) != `{"value":"example"}` {
|
||||||
|
t.Fatalf("Candidate.Payload = %s, want example payload", candidate.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAdapter struct {
|
||||||
|
key string
|
||||||
|
doc *source.SourceDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter fakeAdapter) Key() string {
|
||||||
|
return adapter.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter fakeAdapter) Parse(ctx context.Context, req ParseRequest) (*source.SourceDocument, error) {
|
||||||
|
return adapter.doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExtractor struct {
|
||||||
|
key string
|
||||||
|
artifactType string
|
||||||
|
schemaVersion string
|
||||||
|
validators []Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor fakeExtractor) Key() string {
|
||||||
|
return extractor.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor fakeExtractor) ArtifactType() string {
|
||||||
|
return extractor.artifactType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor fakeExtractor) SchemaVersion() string {
|
||||||
|
return extractor.schemaVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor fakeExtractor) Validators() []Validator {
|
||||||
|
return extractor.validators
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor fakeExtractor) Extract(ctx context.Context, req ExtractionRequest) (ExtractionResult, error) {
|
||||||
|
return ExtractionResult{
|
||||||
|
Candidates: []artifacts.Candidate{
|
||||||
|
{
|
||||||
|
Index: 0,
|
||||||
|
ExtractorKey: extractor.key,
|
||||||
|
ArtifactType: extractor.artifactType,
|
||||||
|
SchemaVersion: extractor.schemaVersion,
|
||||||
|
Payload: json.RawMessage(`{"value":"example"}`),
|
||||||
|
SourceRefs: []source.SourceRef{
|
||||||
|
{
|
||||||
|
SourceID: req.Source.ID,
|
||||||
|
StartUnitID: req.Source.Units[0].ID,
|
||||||
|
EndUnitID: req.Source.Units[0].ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeValidator struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (validator fakeValidator) Name() string {
|
||||||
|
return validator.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (validator fakeValidator) Validate(ctx context.Context, req ValidationRequest) (ValidationResult, error) {
|
||||||
|
decisions := make([]ValidationDecision, 0, len(req.Candidates))
|
||||||
|
for _, candidate := range req.Candidates {
|
||||||
|
decisions = append(decisions, ValidationDecision{
|
||||||
|
CandidateIndex: candidate.Index,
|
||||||
|
Approved: true,
|
||||||
|
ReasonCode: "accepted",
|
||||||
|
Message: "candidate accepted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult{
|
||||||
|
ValidatorName: validator.name,
|
||||||
|
Decisions: decisions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeLLMClient struct{}
|
||||||
|
|
||||||
|
func (client fakeLLMClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest, out any) (StructuredCompletionResponse, error) {
|
||||||
|
return StructuredCompletionResponse{
|
||||||
|
Content: json.RawMessage(`{"value":"example"}`),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user