diff --git a/internal/modules/dnd/extract/enemyevents/assets.go b/internal/modules/dnd/extract/enemyevents/assets.go new file mode 100644 index 0000000..8245ea5 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets.go @@ -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 diff --git a/internal/modules/dnd/extract/enemyevents/assets/prompts/dnd.enemy_events.yaml b/internal/modules/dnd/extract/enemyevents/assets/prompts/dnd.enemy_events.yaml new file mode 100644 index 0000000..e02c7ad --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets/prompts/dnd.enemy_events.yaml @@ -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 diff --git a/internal/modules/dnd/extract/enemyevents/assets/prompts/grounding.md b/internal/modules/dnd/extract/enemyevents/assets/prompts/grounding.md new file mode 100644 index 0000000..a9ef48f --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets/prompts/grounding.md @@ -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" }} diff --git a/internal/modules/dnd/extract/enemyevents/assets/prompts/instructions.md b/internal/modules/dnd/extract/enemyevents/assets/prompts/instructions.md new file mode 100644 index 0000000..b8244cd --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets/prompts/instructions.md @@ -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. diff --git a/internal/modules/dnd/extract/enemyevents/assets/prompts/task.md b/internal/modules/dnd/extract/enemyevents/assets/prompts/task.md new file mode 100644 index 0000000..717d50c --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets/prompts/task.md @@ -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. diff --git a/internal/modules/dnd/extract/enemyevents/assets/schemas/dnd_enemy_events_llm.v1.json b/internal/modules/dnd/extract/enemyevents/assets/schemas/dnd_enemy_events_llm.v1.json new file mode 100644 index 0000000..f574075 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/assets/schemas/dnd_enemy_events_llm.v1.json @@ -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"} + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/extract/enemyevents/extractor.go b/internal/modules/dnd/extract/enemyevents/extractor.go new file mode 100644 index 0000000..d3914a6 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/extractor.go @@ -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...) +} diff --git a/internal/modules/dnd/extract/enemyevents/extractor_test.go b/internal/modules/dnd/extract/enemyevents/extractor_test.go new file mode 100644 index 0000000..b38dd0c --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/extractor_test.go @@ -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 +} diff --git a/internal/modules/dnd/extract/enemyevents/grounding.go b/internal/modules/dnd/extract/enemyevents/grounding.go index 37f24d1..a8213c2 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding.go +++ b/internal/modules/dnd/extract/enemyevents/grounding.go @@ -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) diff --git a/internal/modules/dnd/extract/enemyevents/mapping.go b/internal/modules/dnd/extract/enemyevents/mapping.go new file mode 100644 index 0000000..1d30993 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/mapping.go @@ -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 +} diff --git a/internal/modules/dnd/extract/enemyevents/model.go b/internal/modules/dnd/extract/enemyevents/model.go new file mode 100644 index 0000000..fa8763a --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/model.go @@ -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"` +} diff --git a/internal/modules/dnd/extract/enemyevents/prompt_assets.go b/internal/modules/dnd/extract/enemyevents/prompt_assets.go new file mode 100644 index 0000000..080ba90 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/prompt_assets.go @@ -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 +) diff --git a/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go new file mode 100644 index 0000000..697926e --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/prompt_assets_test.go @@ -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 +} diff --git a/internal/modules/dnd/extract/enemyevents/schema.go b/internal/modules/dnd/extract/enemyevents/schema.go new file mode 100644 index 0000000..c320393 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/schema.go @@ -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", + }) +} diff --git a/internal/modules/dnd/extract/enemyevents/schema_test.go b/internal/modules/dnd/extract/enemyevents/schema_test.go new file mode 100644 index 0000000..adf9152 --- /dev/null +++ b/internal/modules/dnd/extract/enemyevents/schema_test.go @@ -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) +}