Add D&D enemy event extractor

This commit is contained in:
2026-08-03 20:44:44 +00:00
parent c164a3fc69
commit 9dd57dbfa4
15 changed files with 977 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
package enemyevents
import "embed"
//go:embed assets/schemas/dnd_enemy_events_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,55 @@
id: dnd.enemy_events
version: "v1"
default_profile: dnd-extraction
inputs:
- name: transcript
required: true
content_type: application/json
- name: players
required: false
content_type: text/plain
- name: party
required: false
content_type: text/plain
- name: glossary
required: false
content_type: text/plain
- name: npcs
required: true
content_type: application/json
- name: combat_turns
required: true
content_type: application/json
- name: npc_interactions
required: true
content_type: application/json
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
- role: user
content_file: ./sharedassets/common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-npcs.md
- role: user
content_file: ./grounding.md
- role: user
content_file: ./task.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
output:
format: json
validation_mode: json_schema
schema_path: dnd_enemy_events_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,12 @@
Compact combat grounding is supplied below. It can guide attention and
disambiguation, but it is not evidence. Do not derive an event, subject,
outcome, or source range from either list. The current transcript alone must
directly establish every returned event.
Combat-turn grounding:
{{ input "combat_turns" }}
Named combat-opponent grounding:
{{ input "npc_interactions" }}

View File

@@ -0,0 +1,14 @@
Exclude party members, allies, neutral observers, mentioned-but-absent enemies,
hazards, traps, environmental effects, uncertain allegiance, table talk,
planning, hypotheses, recaps outside this passage, and downstream inference.
Do not infer an engagement or outcome from initiative, turn absence, damage,
defeat, movement, a scene ending, combat-opponent grounding, or any auxiliary
artifact. Auxiliary inputs can guide attention but cannot prove or supply an
event. Cite only narrow current-transcript ranges that establish each event.
Return the `events` array even when no enemy event is established. Every event
must contain only `name`, `kind`, and `source_refs`. Use exactly one kind:
`engaged`, `killed`, `fled`, `captured`, or `incapacitated`. Each source range
uses integer `start_unit_id` and `end_unit_id`; omit `source_id` because
Notarius assigns the current source identity.

View File

@@ -0,0 +1,20 @@
Extract Dungeons & Dragons enemy events from the supplied combat transcript.
Return an `engaged` event only when the transcript directly establishes that a
subject is actively opposing the party in combat. Return `killed`, `fled`,
`captured`, or `incapacitated` only when the transcript explicitly establishes
that outcome. An outcome may share evidence with an engagement, and a later
engagement or outcome for the same subject remains a separate observation.
Emit at most one engagement for the same subject in this combat scene.
For `killed`, direct death or killing is required. For `fled`, the subject must
explicitly escape, retreat, or leave combat to avoid continued engagement. For
`captured`, the subject must be explicitly taken prisoner or secured under the
party's control. For `incapacitated`, the subject must be explicitly unable to
continue acting without being established as killed or captured.
Use a normalized NPC registry spelling when the transcript identifies that
named NPC. A hostile creature without a registry entry is allowed. For unnamed
individuals or groups, use only the narrowest transcript-grounded label, such
as `Orcs`, `One orc`, or `Remaining orcs`; never invent member names, IDs, or
quantities.

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.enemy_events.llm",
"type": "object",
"additionalProperties": false,
"required": ["events"],
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"properties": {
"name": {"type": "string"},
"kind": {"type": "string"},
"source_refs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer"},
"end_unit_id": {"type": "integer"}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,210 @@
package enemyevents
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
Key = "dnd/enemy-events"
mappingPolicy = "dnd.enemy_events.extract_mapping.v1"
sceneGatePolicy = "dnd.enemy_events.scene_gate.v1"
)
var requiredCapabilities = []string{
"chunks",
"source.transcript",
}
var providedCapabilities = []string{
"dnd.enemy_events",
}
var _ contracts.Extractor[dnd.EnemyEventList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
grounding *groundingResolver
promptSHA string
responseSchemaSHA string
}
func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) {
if llmClient == nil {
return nil, extractorErrorf("LLM client must not be nil")
}
if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
grounding, err := newGroundingResolver(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare grounding: %w", err)
}
promptSHA, err := promptAssetMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
}
responseSchema, err := loadResponseSchema()
if err != nil {
return nil, extractorErrorf("load response schema: %w", err)
}
return &Extractor{
llm: llmClient,
grounding: grounding,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
}
func (e *Extractor) Key() string { return Key }
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
"mapping_policy": mappingPolicy,
"scene_gate_policy": sceneGatePolicy,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
}
}
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if e == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "scene_gate_policy", Value: sceneGatePolicy},
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("LLM client must not be nil")
}
if e.grounding == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("grounding resolver must not be nil")
}
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("%w", err)
}
match, err := e.grounding.SceneMatch(req.References, req.Chunk)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve scene eligibility: %w", err)
}
switch match.State {
case sceneregistry.MatchExact:
if match.Kind != dnd.SceneKindCombat {
return emptyResult(), nil
}
case sceneregistry.MatchMissing, sceneregistry.MatchMismatched:
return unavailableSceneResult(), nil
default:
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State)
}
grounding, err := e.grounding.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve enemy-event grounding: %w", err)
}
inputs := shared.PromptInputs(sourceInput, req.References)
for name, input := range grounding.PromptInputs() {
inputs[name] = input
}
var response extractionResponse
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: inputs,
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("complete structured output: %w", err)
}
return contracts.TypedExtractionResult[dnd.EnemyEventList]{
Value: canonicalEnemyEventList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID),
}, nil
}
func emptyResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}}
}
func unavailableSceneResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
result := emptyResult()
result.Warnings = []contracts.Warning{{
Scope: SceneDescriptionReferenceSlot,
ReasonCode: "scene_classification_unavailable",
Message: "No exact scene classification was available; enemy-event extraction was skipped.",
}}
return result
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.EnemyEventListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options, request.References)
})
}
func validateOptions(options map[string]any) error {
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, extractorErrorf("%w", err)
}
return Options{}, nil
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd enemy events extractor: "+format, args...)
}

View File

@@ -0,0 +1,229 @@
package enemyevents
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestExtractMapsEnemyEventsInSourceOrder(t *testing.T) {
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{
{Name: "Ashfang", Kind: "killed", SourceRefs: []enemySourceRefResponse{{StartUnitID: 4, EndUnitID: 4}}},
{Name: "Ashfang", Kind: "engaged", SourceRefs: []enemySourceRefResponse{{StartUnitID: 1, EndUnitID: 1}, {StartUnitID: 1, EndUnitID: 1}}},
{Name: "Orcs", Kind: "fled", SourceRefs: []enemySourceRefResponse{{StartUnitID: 3, EndUnitID: 3}}},
{Name: "One orc", Kind: "captured", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
{Name: "Ashfang", Kind: "incapacitated", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
}}}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
if err != nil {
t.Fatal(err)
}
if got := []dnd.EnemyEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.EnemyEventKind{
dnd.EnemyEventKindEngaged,
dnd.EnemyEventKindCaptured,
dnd.EnemyEventKindIncapacitated,
dnd.EnemyEventKindFled,
dnd.EnemyEventKindKilled,
}) {
t.Fatalf("event order = %#v", got)
}
if refs := result.Value.Events[0].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}) {
t.Fatalf("canonical source refs = %#v", refs)
}
if len(client.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
}
request := client.requests[0]
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "enemy-profile" || request.SessionID != "session-123" {
t.Fatalf("LLM request identity = %#v", request)
}
if got := string(request.Inputs[CombatTurnReferenceSlot].Content); !strings.Contains(got, `"actor":"Ashfang"`) || strings.Contains(got, "source_ref") {
t.Fatalf("combat grounding = %s", got)
}
if got := string(request.Inputs[NPCInteractionReferenceSlot].Content); !strings.Contains(got, `"kind":"combat_opponent"`) || strings.Contains(got, "Aria") {
t.Fatalf("interaction grounding = %s", got)
}
}
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
Name: " ", Kind: "unsupported", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
if err != nil {
t.Fatal(err)
}
event := result.Value.Events[0]
if event.Name != " " || event.Kind != "unsupported" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
t.Fatalf("semantic candidate = %#v", event)
}
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
if client.response.Events[0].SourceRefs[0].StartUnitID != 99 {
t.Fatal("mapped event aliases model-owned source references")
}
}
func TestExtractSkipsModelForIneligibleScenes(t *testing.T) {
for _, test := range []struct {
name string
kind dnd.SceneKind
change func(*source.Chunk)
wantWarning bool
}{
{name: "non-combat", kind: dnd.SceneKindNarrative},
{name: "missing", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.ID = "other" }, wantWarning: true},
{name: "mismatched", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.Ref.EndUnitID++ }, wantWarning: true},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeEnemyEventsLLMClient{}
request := enemyExtractionRequest(t)
request.References = groundingReferences(t, "Ashfang", test.kind)
if test.change != nil {
test.change(request.Chunk)
}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if len(client.requests) != 0 || result.Value.Events == nil || len(result.Value.Events) != 0 {
t.Fatalf("result = %#v, calls = %d", result, len(client.requests))
}
if test.wantWarning {
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != "scene_classification_unavailable" || result.Warnings[0].Scope != SceneDescriptionReferenceSlot {
t.Fatalf("warnings = %#v", result.Warnings)
}
} else if len(result.Warnings) != 0 {
t.Fatalf("warnings = %#v", result.Warnings)
}
})
}
}
func TestExtractReportsRequiredGroundingAndProviderFailures(t *testing.T) {
client := &fakeEnemyEventsLLMClient{}
request := enemyExtractionRequest(t)
request.References = withoutSlot(request.References, NPCRegistryReferenceSlot)
if _, err := (&Extractor{llm: client, grounding: mustGroundingResolver(t, request.References)}).Extract(context.Background(), request); err == nil || !strings.Contains(err.Error(), "NPC registry") {
t.Fatalf("Extract() error = %v, want required grounding context", err)
}
provider := &fakeEnemyEventsLLMClient{err: errors.New("provider unavailable")}
if _, err := newEnemyExtractor(t, provider).Extract(context.Background(), enemyExtractionRequest(t)); err == nil || !strings.Contains(err.Error(), "complete structured output") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("Extract() error = %v, want provider context", err)
}
}
func TestConstructorSpecOptionsAndSafeMetadata(t *testing.T) {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
t.Fatalf("New(nil) error = %v", err)
}
if _, err := New(&fakeEnemyEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one") {
t.Fatalf("New() error = %v", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("DecodeOptions() error = %v", err)
}
first := ModuleSpec()
first.Requires[0] = "changed"
first.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
second := ModuleSpec()
if second.Requires[0] != "chunks" || second.ArtifactKind != dnd.EnemyEventListKind || second.ExecutionClass != contracts.ExecutionClassLLMBacked || second.ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
t.Fatalf("ModuleSpec() reused mutable state: %#v", second)
}
registry := pipeline.NewExtractorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if spec, ok := registry.Spec(Key); !ok || spec.Key != Key || spec.ArtifactKind != dnd.EnemyEventListKind {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
extractor := newEnemyExtractor(t, &fakeEnemyEventsLLMClient{}, groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
metadata := extractor.ManifestMetadata()
encoded, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "Ashfang") || metadata["mapping_policy"] != mappingPolicy || metadata["scene_gate_policy"] != sceneGatePolicy {
t.Fatalf("unsafe or incomplete metadata = %s", encoded)
}
fingerprints := extractor.CheckpointFingerprints()
if len(fingerprints) != 4 || fingerprints[0].Value != metadata["prompt_sha256"] || fingerprints[1].Value != metadata["response_schema_sha256"] || fingerprints[2].Value != mappingPolicy || fingerprints[3].Value != sceneGatePolicy {
t.Fatalf("fingerprints = %#v", fingerprints)
}
}
func newEnemyExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
t.Helper()
if len(references) == 0 {
references = []contracts.ReferenceSet{groundingReferences(t, "Ashfang", dnd.SceneKindCombat)}
}
extractor, err := New(client, Options{}, references...)
if err != nil {
t.Fatal(err)
}
return extractor
}
func mustGroundingResolver(t *testing.T, references contracts.ReferenceSet) *groundingResolver {
t.Helper()
resolver, err := newGroundingResolver(references)
if err != nil {
t.Fatal(err)
}
return resolver
}
func enemyExtractionRequest(t *testing.T) contracts.TypedExtractionRequest {
t.Helper()
chunk := combatChunk()
chunk.Units = []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}
chunk.MediaType = "application/json"
chunk.Content = []byte(`{"id":"combat-scene","units":[1,2,3,4]}`)
return contracts.TypedExtractionRequest{
Source: &source.SourceDocument{ID: "combat-session", Units: append([]source.SourceUnit(nil), chunk.Units...)},
Chunk: chunk,
SourceInput: contracts.NewLLMInputMaterial("source", "application/json", chunk.Content, digest(chunk.Content), "file:///combat-session.json"),
References: groundingReferences(t, "Ashfang", dnd.SceneKindCombat),
LLMProfile: "enemy-profile",
SessionID: "session-123",
}
}
type fakeEnemyEventsLLMClient struct {
response extractionResponse
err error
requests []contracts.StructuredCompletionRequest
}
func (client *fakeEnemyEventsLLMClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneEnemyRequest(request))
if client.err != nil {
return contracts.StructuredCompletionResponse{}, client.err
}
target, ok := out.(*extractionResponse)
if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
}
*target = client.response
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func cloneEnemyRequest(request contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
request.Inputs = request.Inputs.Clone()
return request
}

View File

@@ -130,12 +130,9 @@ func (r *groundingResolver) Resolve(references contracts.ReferenceSet) (groundin
if !npcs.Bound() {
return grounding{}, fmt.Errorf("NPC registry reference is required")
}
scenes, err := r.scenes.Resolve(references)
scenes, err := r.resolveScenes(references)
if err != nil {
return grounding{}, fmt.Errorf("resolve scene eligibility: %w", err)
}
if !scenes.Bound() {
return grounding{}, fmt.Errorf("scene descriptions reference is required")
return grounding{}, err
}
combatTurns, err := resolveInput(references, CombatTurnReferenceSlot, r.combatTurns, prepareCombatTurnInput)
if err != nil {
@@ -153,6 +150,28 @@ func (r *groundingResolver) Resolve(references contracts.ReferenceSet) (groundin
}, nil
}
func (r *groundingResolver) SceneMatch(references contracts.ReferenceSet, chunk *source.Chunk) (sceneregistry.ChunkMatch, error) {
if r == nil {
return sceneregistry.ChunkMatch{}, fmt.Errorf("grounding resolver must not be nil")
}
scenes, err := r.resolveScenes(references)
if err != nil {
return sceneregistry.ChunkMatch{}, err
}
return scenes.Match(chunk), nil
}
func (r *groundingResolver) resolveScenes(references contracts.ReferenceSet) (*sceneregistry.Registry, error) {
scenes, err := r.scenes.Resolve(references)
if err != nil {
return nil, fmt.Errorf("resolve scene eligibility: %w", err)
}
if !scenes.Bound() {
return nil, fmt.Errorf("scene descriptions reference is required")
}
return scenes, nil
}
func resolveInput(references contracts.ReferenceSet, slot string, seeded *contracts.LLMInputMaterial, prepare func(contracts.ReferenceSet) (*contracts.LLMInputMaterial, error)) (contracts.LLMInputMaterial, error) {
if _, ok := references.Slots[slot]; ok {
value, err := prepare(references)

View File

@@ -0,0 +1,60 @@
package enemyevents
import (
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type orderedEnemyEvent struct {
value dnd.EnemyEvent
earliest int
hasEvidence bool
}
func canonicalEnemyEventList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.EnemyEventList {
if response.Events == nil {
return dnd.EnemyEventList{}
}
ordered := make([]orderedEnemyEvent, len(response.Events))
for index, event := range response.Events {
refs := order.Canonicalize(sourceRefs(event.SourceRefs, sourceID))
earliest, hasEvidence := order.EarliestValid(refs)
ordered[index] = orderedEnemyEvent{
value: dnd.EnemyEvent{
Name: event.Name,
Kind: dnd.EnemyEventKind(event.Kind),
SourceRefs: refs,
},
earliest: earliest,
hasEvidence: hasEvidence,
}
}
sort.SliceStable(ordered, func(left, right int) bool {
if ordered[left].hasEvidence != ordered[right].hasEvidence {
return ordered[left].hasEvidence
}
if !ordered[left].hasEvidence {
return false
}
return ordered[left].earliest < ordered[right].earliest
})
events := make([]dnd.EnemyEvent, len(ordered))
for index := range ordered {
events[index] = ordered[index].value
}
return dnd.EnemyEventList{Events: events}
}
func sourceRefs(refs []enemySourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil {
return nil
}
values := make([]source.SourceRef, len(refs))
for index, ref := range refs {
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return values
}

View File

@@ -0,0 +1,16 @@
package enemyevents
type extractionResponse struct {
Events []enemyEventResponse `json:"events"`
}
type enemyEventResponse struct {
Name string `json:"name"`
Kind string `json:"kind"`
SourceRefs []enemySourceRefResponse `json:"source_refs"`
}
type enemySourceRefResponse struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}

View File

@@ -0,0 +1,54 @@
package enemyevents
import (
"fmt"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: "dnd.enemy_events",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.enemy_events.yaml", Path: "assets/prompts/dnd.enemy_events.yaml"},
{Name: "grounding.md", Path: "assets/prompts/grounding.md"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-npcs.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil {
return fmt.Errorf("prepare enemy-event prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptAssetMetadata() (string, error) {
promptAssetHashOnce.Do(func() {
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(embeddedAssets)
})
return promptAssetHash, promptAssetHashErr
}
var (
promptAssetHashOnce sync.Once
promptAssetHash string
promptAssetHashErr error
)

View File

@@ -0,0 +1,126 @@
package enemyevents
import (
"context"
"io/fs"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterPromptAssetsAndPrepareEnemyEventPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
schemaFS, err := registry.SchemaFS()
if err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(schemaFS, "dnd_enemy_events_llm.v1.json"); err != nil {
t.Fatalf("response schema asset: %v", err)
}
prepared := prepareEnemyEventPrompt(t)
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_enemy_events_llm.v1.json" || prepared.SelectedProfileID != "dnd-extraction" {
t.Fatalf("prepared prompt = %#v", prepared)
}
transcriptIndex := -1
for index, message := range prepared.Messages {
if strings.Contains(message.Content, "enemy-transcript") {
transcriptIndex = index
break
}
}
if transcriptIndex != 3 {
t.Fatalf("transcript message index = %d, want shared-prefix index 3", transcriptIndex)
}
for index, role := range []string{"system", "user", "user", "user"} {
if prepared.Messages[index].Role != role {
t.Fatalf("message %d role = %q, want %q", index, prepared.Messages[index].Role, role)
}
}
for _, index := range []int{2, 3} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Fatalf("message %d cache control = %#v", index, cache)
}
}
for _, index := range []int{0, 1} {
if prepared.Messages[index].CacheControl != nil {
t.Fatalf("message %d cache control = %#v, want nil", index, prepared.Messages[index].CacheControl)
}
}
if !strings.Contains(prepared.Messages[transcriptIndex+3].Content, "enemy-turn") || !strings.Contains(prepared.Messages[transcriptIndex+3].Content, "enemy-opponent") {
t.Fatalf("combat grounding message = %q", prepared.Messages[transcriptIndex+3].Content)
}
}
func TestEnemyEventPromptRequiresGroundingInputs(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatal(err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "dnd-extraction", Endpoint: "http://127.0.0.1:1/v1", Model: "enemy-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatal(err)
}
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "dnd-extraction",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline(`{"value":"enemy-transcript"}`),
"players": promptkit.Inline(" "),
"party": promptkit.Inline(" "),
"glossary": promptkit.Inline(" "),
"npcs": promptkit.Inline(`{"npcs":[]}`),
"npc_interactions": promptkit.Inline(`{"npc_interactions":[]}`),
},
})
if err == nil || !strings.Contains(err.Error(), "combat_turns") {
t.Fatalf("Prepare() error = %v, want required combat-turn input", err)
}
}
func prepareEnemyEventPrompt(t *testing.T) *promptkit.PreparedRun {
t.Helper()
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatal(err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "dnd-extraction", Endpoint: "http://127.0.0.1:1/v1", Model: "enemy-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "dnd-extraction",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline(`{"value":"enemy-transcript"}`),
"players": promptkit.Inline("enemy-player"),
"party": promptkit.Inline("enemy-party"),
"glossary": promptkit.Inline("enemy-glossary"),
"npcs": promptkit.Inline(`{"npcs":[{"name":"enemy-npc"}]}`),
"combat_turns": promptkit.Inline(`{"combat_turns":[{"actor":"enemy-turn","turn_kind":"turn"}]}`),
"npc_interactions": promptkit.Inline(`{"npc_interactions":[{"name":"enemy-opponent","kind":"combat_opponent"}]}`),
},
})
if err != nil {
t.Fatal(err)
}
return prepared
}

View File

@@ -0,0 +1,21 @@
package enemyevents
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.enemy_events"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_enemy_events_llm")
ResponseSchemaID = "notarius.dnd.enemy_events.llm"
ResponseSchemaName = "notarius_dnd_enemy_events_llm_v1"
SchemaVersion = "v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_enemy_events_llm.v1.json",
})
}

View File

@@ -0,0 +1,97 @@
package enemyevents
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v", schema)
}
valid := validEnemyResponse()
content, err := json.Marshal(valid)
if err != nil {
t.Fatal(err)
}
if err := validateEnemySchema(content, schema.JSONSchema); err != nil {
t.Fatalf("valid response rejected: %v", err)
}
semantic := validEnemyResponse()
event := semantic["events"].([]any)[0].(map[string]any)
event["name"] = ""
event["kind"] = "unsupported"
ref := event["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0
ref["end_unit_id"] = -1
content, err = json.Marshal(semantic)
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
t.Fatalf("validator-owned semantics were rejected: %v", err)
}
}
func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
for _, mutate := range []func(map[string]any){
func(event map[string]any) { delete(event, "name") },
func(event map[string]any) { event["kind"] = 1 },
func(event map[string]any) { event["unexpected"] = true },
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
} {
candidate := validEnemyResponse()
mutate(candidate["events"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate)
if err != nil {
t.Fatal(err)
}
if err := validateEnemySchema(content, schema.JSONSchema); err == nil {
t.Fatal("private schema accepted structurally invalid response")
}
}
first, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
t.Fatalf("schema defensive copy = %s, %v", second.JSONSchema, err)
}
}
func validEnemyResponse() map[string]any {
return map[string]any{"events": []any{map[string]any{
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
}}}
}
func validateEnemySchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", document); err != nil {
return err
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return schema.Validate(instance)
}