diff --git a/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json b/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json index bb452ba..025cc1c 100644 --- a/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json +++ b/assets/dnd/item-occurrences/schemas/dnd_item_occurrences_llm.v1.json @@ -22,10 +22,10 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["start_segment", "end_segment"], + "required": ["start_unit_id", "end_unit_id"], "properties": { - "start_segment": {"type": "integer"}, - "end_segment": {"type": "integer"} + "start_unit_id": {"type": "integer"}, + "end_unit_id": {"type": "integer"} } } } diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index 90af2f8..886de26 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -330,7 +330,7 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" { return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items)) } - content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`) + content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":5,"end_unit_id":5}]}]}`) } case combat.PromptID: content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) diff --git a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go index 0a59060..4b7c84e 100644 --- a/internal/modules/dnd/extract/itemoccurrences/canonicalize.go +++ b/internal/modules/dnd/extract/itemoccurrences/canonicalize.go @@ -64,7 +64,7 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s } values := make([]source.SourceRef, len(refs)) for index, ref := range refs { - values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartSegment, EndUnitID: ref.EndSegment} + values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID} } return values } diff --git a/internal/modules/dnd/extract/itemoccurrences/extractor_test.go b/internal/modules/dnd/extract/itemoccurrences/extractor_test.go index acc50d5..5961024 100644 --- a/internal/modules/dnd/extract/itemoccurrences/extractor_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/extractor_test.go @@ -23,6 +23,9 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" { t.Fatalf("occurrences = %#v", result.Value.Occurrences) } + if refs := result.Value.Occurrences[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != req.Source.ID || refs[0].StartUnitID != 1 || refs[0].EndUnitID != 1 { + t.Fatalf("occurrence evidence = %#v, want current-source unit range", refs) + } input := client.requests[0].Inputs[ItemRegistryReferenceSlot] if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") { t.Fatalf("registry prompt input = %#v, want names-only projection", input) @@ -98,7 +101,7 @@ func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) { } func TestExtractPreservesNullableFields(t *testing.T) { - client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} + client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) diff --git a/internal/modules/dnd/extract/itemoccurrences/model.go b/internal/modules/dnd/extract/itemoccurrences/model.go index d450bf6..2f5bb0d 100644 --- a/internal/modules/dnd/extract/itemoccurrences/model.go +++ b/internal/modules/dnd/extract/itemoccurrences/model.go @@ -14,6 +14,6 @@ type itemOccurrenceResponse struct { } type itemOccurrenceSourceRefResponse struct { - StartSegment int `json:"start_segment"` - EndSegment int `json:"end_segment"` + StartUnitID int `json:"start_unit_id"` + EndUnitID int `json:"end_unit_id"` } diff --git a/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go index cfac6d2..cd9bf7f 100644 --- a/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/prompt_assets_test.go @@ -43,6 +43,19 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" { t.Fatalf("prepared prompt = %#v", prepared) } + rendered := make([]string, len(prepared.Messages)) + for index, message := range prepared.Messages { + rendered[index] = message.Content + } + content := strings.Join(rendered, "\n") + for _, field := range []string{"start_unit_id", "end_unit_id", "source_id"} { + if !strings.Contains(content, field) { + t.Fatalf("prepared prompt does not include shared evidence field %q", field) + } + } + if strings.Contains(content, "start_segment") || strings.Contains(content, "end_segment") { + t.Fatalf("prepared prompt contains obsolete segment evidence fields: %s", content) + } } func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) { diff --git a/internal/modules/dnd/extract/itemoccurrences/schema_test.go b/internal/modules/dnd/extract/itemoccurrences/schema_test.go index 6ca1505..1a13a11 100644 --- a/internal/modules/dnd/extract/itemoccurrences/schema_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/schema_test.go @@ -20,11 +20,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { valid := map[string]any{"occurrences": []any{ map[string]any{ "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", - "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}}, + "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}}, }, map[string]any{ "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, - "source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}}, + "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}, }, }} content, err := json.Marshal(valid) @@ -43,8 +43,10 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { {"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}}, {"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}}, {"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}}, - {"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}}, - {"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, + {"segment-named range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1}})}}}, + {"opaque source identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1, "source_id": "session-alpha"}})}}}, + {"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1, "extra": true}})}}}, + {"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 1}})}}}, } { t.Run(test.name, func(t *testing.T) { content, err := json.Marshal(test.value) diff --git a/internal/modules/dnd/extract/itemoccurrences/test_helpers_test.go b/internal/modules/dnd/extract/itemoccurrences/test_helpers_test.go index 80fe78d..9b2d0b1 100644 --- a/internal/modules/dnd/extract/itemoccurrences/test_helpers_test.go +++ b/internal/modules/dnd/extract/itemoccurrences/test_helpers_test.go @@ -44,7 +44,7 @@ func sourceDocument() *source.SourceDocument { } func responseRefs(start, end int) []itemOccurrenceSourceRefResponse { - return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}} + return []itemOccurrenceSourceRefResponse{{StartUnitID: start, EndUnitID: end}} } func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {