Add generic semantic reconciliation prompt assets

This commit is contained in:
2026-08-09 16:08:53 +00:00
parent 297d58f090
commit b630384aa0
15 changed files with 441 additions and 23 deletions

View File

@@ -0,0 +1,111 @@
package semanticreconcile
import (
"errors"
"fmt"
"io/fs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
)
const (
PromptID = "generic.semantic_reconciliation"
PromptVersion = "v1"
promptRoot = "assets/prompts"
)
var promptFiles = []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "system.md", Path: "prompts/system.md"},
{Name: "protocol.md", Path: "prompts/protocol.md"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
{Name: "transcript-windows.md", Path: "prompts/transcript-windows.md"},
}
// RegisterAssets registers the generic reconciliation prompt and response
// schema as one production-owned asset set.
func RegisterAssets(registry *llm.AssetRegistry) error {
if registry == nil {
return fmt.Errorf("semantic reconciliation asset registry must not be nil")
}
if err := ensureAssetsAbsent(registry); err != nil {
return err
}
assets, err := assetFS()
if err != nil {
return err
}
prompts, err := promptfs.ModulePromptFS(PromptID, assets, append([]promptfs.ModulePromptFile(nil), promptFiles...))
if err != nil {
return fmt.Errorf("prepare semantic reconciliation prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(prompts, promptRoot); err != nil {
return fmt.Errorf("register semantic reconciliation prompt assets: %w", err)
}
if err := registry.RegisterSchemaFS(assets, "schemas"); err != nil {
return fmt.Errorf("register semantic reconciliation schema assets: %w", err)
}
return nil
}
// PromptHash returns the deterministic identity of the complete generic prompt.
func PromptHash() (string, error) {
assets, err := assetFS()
if err != nil {
return "", err
}
parts := make([]llm.AssetHashPart, 0, len(promptFiles))
for _, file := range promptFiles {
parts = append(parts, llm.AssetHashPart{FS: assets, Path: file.Path})
}
return llm.HashAssets(parts)
}
// SchemaHash returns the deterministic identity of the response schema.
func SchemaHash() (string, error) {
schema, err := LoadResponseSchema()
if err != nil {
return "", err
}
return schema.SHA256, nil
}
// SharedPromptFiles returns fresh descriptors for the mandatory protocol and
// variable-input presentation assets that domain prompts may reuse.
func SharedPromptFiles() ([]promptfs.SharedPromptFile, error) {
assets, err := assetFS()
if err != nil {
return nil, err
}
return []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: assets, Path: "prompts/protocol.md"},
{Name: "candidates.md", FS: assets, Path: "prompts/candidates.md"},
{Name: "transcript-windows.md", FS: assets, Path: "prompts/transcript-windows.md"},
}, nil
}
func ensureAssetsAbsent(registry *llm.AssetRegistry) error {
prompts, err := registry.PromptFS()
if err != nil {
return fmt.Errorf("inspect registered prompt assets: %w", err)
}
if _, err := fs.Stat(prompts, PromptID+"/prompt.yaml"); err == nil {
return fmt.Errorf("semantic reconciliation prompt assets already registered")
} else if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("inspect semantic reconciliation prompt assets: %w", err)
}
schemas, err := registry.SchemaFS()
if err != nil {
return fmt.Errorf("inspect registered schema assets: %w", err)
}
if _, err := fs.Stat(schemas, "semantic_reconciliation_llm.v1.json"); err == nil {
return fmt.Errorf("semantic reconciliation schema assets already registered")
} else if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("inspect semantic reconciliation schema assets: %w", err)
}
return nil
}

View File

@@ -0,0 +1,118 @@
package semanticreconcile
import (
"context"
"io/fs"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterAssets(registry); err != nil {
t.Fatalf("RegisterAssets() error = %v, want nil", err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatalf("PromptKitOptions() error = %v, want nil", err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "semantic-reconciliation-test", Endpoint: "http://127.0.0.1:1/v1", Model: "offline-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira"},{"candidate_id":2,"label":"Captain Mira"}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[{"unit_id":7,"text":"Mira arrived."}]}]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if prepared.PromptID != PromptID || prepared.PromptVersion != PromptVersion {
t.Fatalf("prepared prompt identity = %q %q, want %q %q", prepared.PromptID, prepared.PromptVersion, PromptID, PromptVersion)
}
if prepared.SelectedProfileID != "semantic-reconciliation-test" {
t.Fatalf("selected profile = %q, want explicit test profile", prepared.SelectedProfileID)
}
if contract := prepared.OutputContract; contract.SchemaPath != "semantic_reconciliation_llm.v1.json" || contract.RepairAttempts != 0 {
t.Fatalf("output contract = %#v, want generic schema without repair", contract)
}
if len(prepared.Messages) != 5 || prepared.Messages[0].Role != "system" {
t.Fatalf("prepared messages = %#v, want five ordered messages beginning with system", prepared.Messages)
}
if cache := prepared.Messages[2].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Fatalf("semantic policy cache control = %#v, want ephemeral", cache)
}
for _, index := range []int{0, 1, 3, 4} {
if prepared.Messages[index].CacheControl != nil {
t.Fatalf("message %d cache control = %#v, want nil", index, prepared.Messages[index].CacheControl)
}
}
protocol := prepared.Messages[1].Content
for _, requirement := range []string{"positive integer", "Return IDs only", "do not copy candidate names", "source ranges"} {
if !strings.Contains(protocol, requirement) {
t.Fatalf("protocol message = %q, want requirement %q", protocol, requirement)
}
}
if !strings.Contains(prepared.Messages[3].Content, `"candidate_id":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q, want only integer candidate material", prepared.Messages[3].Content)
}
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"candidate_id"`) {
t.Fatalf("transcript message = %q, want only transcript windows", prepared.Messages[4].Content)
}
}
func TestAssetHashesAreDeterministicAndComplete(t *testing.T) {
firstPrompt, err := PromptHash()
if err != nil {
t.Fatal(err)
}
secondPrompt, err := PromptHash()
if err != nil {
t.Fatal(err)
}
schemaHash, err := SchemaHash()
if err != nil {
t.Fatal(err)
}
if firstPrompt == "" || firstPrompt != secondPrompt || schemaHash == "" || firstPrompt == schemaHash {
t.Fatalf("asset hashes = prompt %q/%q schema %q, want stable distinct hashes", firstPrompt, secondPrompt, schemaHash)
}
}
func TestSharedPromptFilesExposeOnlyReusableCoreAssets(t *testing.T) {
first, err := SharedPromptFiles()
if err != nil {
t.Fatal(err)
}
second, err := SharedPromptFiles()
if err != nil {
t.Fatal(err)
}
wantNames := []string{"protocol.md", "candidates.md", "transcript-windows.md"}
gotNames := make([]string, len(first))
for index, file := range first {
gotNames[index] = file.Name
if content, err := fs.ReadFile(file.FS, file.Path); err != nil || len(content) == 0 {
t.Fatalf("shared file %q = %q, %v; want readable content", file.Name, content, err)
}
}
if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("shared files = %#v, want narrow allowlist %#v", gotNames, wantNames)
}
first[0].Name = "changed.md"
if second[0].Name != "protocol.md" {
t.Fatalf("SharedPromptFiles() reused mutable descriptors: %#v", second)
}
}

View File

@@ -16,7 +16,7 @@ const (
SchemaAssetPath = "schemas/semantic_reconciliation_llm.v1.json"
)
func schemaAssetFS() (fs.FS, error) {
func assetFS() (fs.FS, error) {
assets, err := fs.Sub(rootassets.FS(), "generic/normalize/deduplication")
if err != nil {
return nil, fmt.Errorf("scope semantic reconciliation assets: %w", err)
@@ -27,7 +27,7 @@ func schemaAssetFS() (fs.FS, error) {
// LoadResponseSchema returns the private request-local integer proposal
// contract. It is separate from every durable artifact schema.
func LoadResponseSchema() (llm.ResponseSchema, error) {
assets, err := schemaAssetFS()
assets, err := assetFS()
if err != nil {
return llm.ResponseSchema{}, err
}