Relax private D&D response schemas
This commit is contained in:
@@ -20,6 +20,11 @@ the Unicode-normalized, case-folded canonical name using the identity policy.
|
|||||||
The durable codec enforces the artifact shape and ID syntax; registry identity
|
The durable codec enforces the artifact shape and ID syntax; registry identity
|
||||||
validation remains a separate deterministic concern.
|
validation remains a separate deterministic concern.
|
||||||
|
|
||||||
|
The extractor's private LLM response schema is a separate structural transport
|
||||||
|
contract. It omits framework-assigned NPC and source IDs and admits semantic
|
||||||
|
candidates for the deterministic shape and source-reference validators; it is
|
||||||
|
not part of this durable contract.
|
||||||
|
|
||||||
## Output Shape
|
## Output Shape
|
||||||
|
|
||||||
The payload is one object with a required top-level `npcs` array:
|
The payload is one object with a required top-level `npcs` array:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ The durable JSON Schema is owned by the D&D spell artifact codec. The
|
|||||||
extractor's private LLM response schema is a separate transport contract: its
|
extractor's private LLM response schema is a separate transport contract: its
|
||||||
source-reference objects omit `source_id`, which the extractor assigns while
|
source-reference objects omit `source_id`, which the extractor assigns while
|
||||||
mapping the response to the canonical artifact. The LLM DTO and transport
|
mapping the response to the canonical artifact. The LLM DTO and transport
|
||||||
schema are not part of this durable contract.
|
schema are not part of this durable contract. The private schema owns required
|
||||||
|
fields, JSON types, object and array shapes, and unknown-field rejection;
|
||||||
|
deterministic validators own the durable artifact's semantic constraints.
|
||||||
|
|
||||||
The output contains canonical spell casts derived from transcript evidence.
|
The output contains canonical spell casts derived from transcript evidence.
|
||||||
Source IDs are assigned from the input identity; source-unit ranges identify
|
Source IDs are assigned from the input identity; source-unit ranges identify
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ schemas remain package-owned.
|
|||||||
The spell, NPC, and combat extractors' package-owned prompts declare their
|
The spell, NPC, and combat extractors' package-owned prompts declare their
|
||||||
structured JSON inputs and private response schemas. Each private response
|
structured JSON inputs and private response schemas. Each private response
|
||||||
schema remains separate from its durable artifact codec schema; this work does
|
schema remains separate from its durable artifact codec schema; this work does
|
||||||
not use shared schema fragments or schema generation. The combat private schema
|
not use shared schema fragments or schema generation. Those private schemas own
|
||||||
owns the transport envelope—required fields, JSON types, nullability, and
|
the transport envelope—required fields, JSON types, nullability, and
|
||||||
unknown-field rejection—while its deterministic validators own semantic
|
unknown-field rejection—while deterministic validators own semantic constraints
|
||||||
constraints such as enum membership, non-empty values and collections, and
|
such as enum membership, non-empty values and collections, and positive
|
||||||
positive numbers. The spell extractor's prompt declares a required
|
numbers. The spell extractor's prompt declares a required
|
||||||
`application/json` `spell_catalog` input and an optional `application/json`
|
`application/json` `spell_catalog` input and an optional `application/json`
|
||||||
`npcs` input. The extractor generates
|
`npcs` input. The extractor generates
|
||||||
the catalog input from its prepared
|
the catalog input from its prepared
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ decodes the model response, assigns the generic source identity to every source
|
|||||||
reference, canonicalizes duplicate references, orders spell casts by their
|
reference, canonicalizes duplicate references, orders spell casts by their
|
||||||
earliest cited unit, and returns `dnd.SpellList`.
|
earliest cited unit, and returns `dnd.SpellList`.
|
||||||
|
|
||||||
|
Its private response schema admits only the structural transport envelope:
|
||||||
|
required fields, JSON types, array and object shapes, and unknown-field
|
||||||
|
rejection. It maps integer source-unit candidates directly without repairing
|
||||||
|
semantic values, so the deterministic shape, catalog, and source-reference
|
||||||
|
validators own blank values, empty evidence, and invalid or unresolved ranges.
|
||||||
|
|
||||||
The extractor owns its private model-response DTO, embedded prompt, LLM response
|
The extractor owns its private model-response DTO, embedded prompt, LLM response
|
||||||
schema, strict option decoder, injected shared LLM client, and prompt/schema
|
schema, strict option decoder, injected shared LLM client, and prompt/schema
|
||||||
manifest metadata. During preparation it resolves the optional `spell_catalog`
|
manifest metadata. During preparation it resolves the optional `spell_catalog`
|
||||||
@@ -250,6 +256,11 @@ registry slot. Its prompt and private response schema are package-owned. The
|
|||||||
prompt follows the shared D&D extraction ordering and cache policy documented
|
prompt follows the shared D&D extraction ordering and cache policy documented
|
||||||
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
|
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
|
||||||
|
|
||||||
|
The private response schema owns only structural transport validation and maps
|
||||||
|
integer source-unit candidates unchanged. Required semantic content, non-empty
|
||||||
|
evidence, and valid source ranges are rejected by the deterministic shape and
|
||||||
|
source-reference validators.
|
||||||
|
|
||||||
### `internal/modules/dnd/extract/combatturns`
|
### `internal/modules/dnd/extract/combatturns`
|
||||||
|
|
||||||
The combat extractor prepares one structured request per supplied chunk using
|
The combat extractor prepares one structured request per supplied chunk using
|
||||||
|
|||||||
@@ -19,19 +19,16 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"relationships": {
|
"relationships": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -41,31 +38,26 @@
|
|||||||
"required": ["target", "relationship"],
|
"required": ["target", "relationship"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"target": {
|
"target": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"relationship": {
|
"relationship": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["start_unit_id", "end_unit_id"],
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
},
|
},
|
||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
||||||
@@ -33,10 +32,6 @@ func canonicalizeNPC(npc *npcResponse) {
|
|||||||
if npc == nil {
|
if npc == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for index := range npc.SourceRefs {
|
|
||||||
npc.SourceRefs[index].StartUnitID = canonicalUnitRef(npc.SourceRefs[index].StartUnitID)
|
|
||||||
npc.SourceRefs[index].EndUnitID = canonicalUnitRef(npc.SourceRefs[index].EndUnitID)
|
|
||||||
}
|
|
||||||
sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
|
sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
|
||||||
left := npc.SourceRefs[i]
|
left := npc.SourceRefs[i]
|
||||||
right := npc.SourceRefs[j]
|
right := npc.SourceRefs[j]
|
||||||
@@ -48,14 +43,6 @@ func canonicalizeNPC(npc *npcResponse) {
|
|||||||
npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs)
|
npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
|
|
||||||
value := ref.Int()
|
|
||||||
if value <= 0 {
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
return shared.UnitRefFromInt(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
|
func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
|
||||||
if len(refs) < 2 {
|
if len(refs) < 2 {
|
||||||
return refs
|
return refs
|
||||||
@@ -73,16 +60,15 @@ func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
|
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
|
||||||
return left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
||||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
|
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
|
||||||
earliest := 0
|
earliest := 0
|
||||||
found := false
|
found := false
|
||||||
for _, ref := range npc.SourceRefs {
|
for _, ref := range npc.SourceRefs {
|
||||||
start := ref.StartUnitID.Int()
|
start := ref.StartUnitID
|
||||||
end := ref.EndUnitID.Int()
|
end := ref.EndUnitID
|
||||||
if start > 0 && end > 0 {
|
if start > 0 && end > 0 {
|
||||||
startIndex, startOK := source.UnitIndex(doc, start)
|
startIndex, startOK := source.UnitIndex(doc, start)
|
||||||
endIndex, endOK := source.UnitIndex(doc, end)
|
endIndex, endOK := source.UnitIndex(doc, end)
|
||||||
@@ -98,8 +84,7 @@ func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool
|
|||||||
return earliest, found
|
return earliest, found
|
||||||
}
|
}
|
||||||
|
|
||||||
func unitSortValue(ref shared.UnitRef) int {
|
func unitSortValue(value int) int {
|
||||||
value := ref.Int()
|
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return int(^uint(0) >> 1)
|
return int(^uint(0) >> 1)
|
||||||
}
|
}
|
||||||
@@ -150,8 +135,8 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
|
|||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
out[index] = source.SourceRef{
|
out[index] = source.SourceRef{
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
StartUnitID: value.StartUnitID.Int(),
|
StartUnitID: value.StartUnitID,
|
||||||
EndUnitID: value.EndUnitID.Int(),
|
EndUnitID: value.EndUnitID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
||||||
@@ -25,9 +24,9 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
|||||||
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
|
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
|
||||||
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
|
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
|
||||||
SourceRefs: []npcSourceRefResponse{
|
SourceRefs: []npcSourceRefResponse{
|
||||||
{StartUnitID: sharedUnitRef(2), EndUnitID: sharedUnitRef(2)},
|
{StartUnitID: 2, EndUnitID: 2},
|
||||||
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)},
|
{StartUnitID: 1, EndUnitID: 2},
|
||||||
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)},
|
{StartUnitID: 1, EndUnitID: 2},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
@@ -65,8 +64,8 @@ func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{},
|
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{},
|
||||||
SourceRefs: []npcSourceRefResponse{
|
SourceRefs: []npcSourceRefResponse{
|
||||||
{StartUnitID: sharedUnitRef(50), EndUnitID: sharedUnitRef(50)},
|
{StartUnitID: 50, EndUnitID: 50},
|
||||||
{StartUnitID: sharedUnitRef(100), EndUnitID: sharedUnitRef(100)},
|
{StartUnitID: 100, EndUnitID: 100},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
@@ -111,7 +110,7 @@ func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
|
|||||||
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
|
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
|
||||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
|
||||||
Name: "", Aliases: nil, Description: "", Relationships: nil,
|
Name: "", Aliases: nil, Description: "", Relationships: nil,
|
||||||
SourceRefs: []npcSourceRefResponse{{StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromString("missing")}, {StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromInt(0)}},
|
SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||||
}}}}
|
}}}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,6 +124,21 @@ func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
||||||
|
client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","aliases":[],"description":"","relationships":[],"source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
npc := result.Value.NPCs[0]
|
||||||
|
if npc.ID != "" || npc.Name != "" || npc.Description != "" {
|
||||||
|
t.Fatalf("NPC = %#v, want blank semantic values preserved", npc)
|
||||||
|
}
|
||||||
|
if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
|
||||||
|
t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
||||||
request := extractionRequest()
|
request := extractionRequest()
|
||||||
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
||||||
@@ -142,5 +156,3 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
|||||||
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sharedUnitRef(value int) shared.UnitRef { return shared.UnitRefFromInt(value) }
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package npcs
|
package npcs
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
NPCs []npcResponse `json:"npcs"`
|
NPCs []npcResponse `json:"npcs"`
|
||||||
}
|
}
|
||||||
@@ -20,6 +18,6 @@ type npcRelationshipResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type npcSourceRefResponse struct {
|
type npcSourceRefResponse struct {
|
||||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
StartUnitID int `json:"start_unit_id"`
|
||||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
EndUnitID int `json:"end_unit_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,66 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
|
|||||||
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("valid private NPC response rejected: %v", err)
|
t.Fatalf("valid private NPC response rejected: %v", err)
|
||||||
}
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
response map[string]any
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "semantic blanks and empty collections",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"name": "", "aliases": []any{""}, "description": "", "relationships": []any{map[string]any{"target": "", "relationship": ""}}, "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonpositive unit candidates",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{},
|
||||||
|
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
|
||||||
|
}}},
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required field",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{}, "id": "assigned later",
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong field type",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"name": 7, "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noninteger source identifier",
|
||||||
|
response: map[string]any{"npcs": []any{map[string]any{
|
||||||
|
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{},
|
||||||
|
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
content, err := json.Marshal(test.response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = validateJSONSchema(content, schema.JSONSchema)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("validateJSONSchema() error = %v, want valid=%t", err, test.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema([]byte(`{"npcs":`), schema.JSONSchema); err == nil {
|
||||||
|
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
|
||||||
|
}
|
||||||
withID := map[string]any{"npcs": []any{map[string]any{
|
withID := map[string]any{"npcs": []any{map[string]any{
|
||||||
"name": "Mira Thorn", "id": "assigned-later", "aliases": []any{}, "description": "A ranger.", "relationships": []any{},
|
"name": "Mira Thorn", "id": "assigned-later", "aliases": []any{}, "description": "A ranger.", "relationships": []any{},
|
||||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractionRequest() contracts.TypedExtractionRequest {
|
func extractionRequest() contracts.TypedExtractionRequest {
|
||||||
@@ -47,7 +46,7 @@ func sourceDocument() *source.SourceDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func responseSourceRefs(startUnitID, endUnitID int) []npcSourceRefResponse {
|
func responseSourceRefs(startUnitID, endUnitID int) []npcSourceRefResponse {
|
||||||
return []npcSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(startUnitID), EndUnitID: shared.UnitRefFromInt(endUnitID)}}
|
return []npcSourceRefResponse{{StartUnitID: startUnitID, EndUnitID: endUnitID}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
||||||
@@ -99,9 +98,13 @@ func (client *fakeNPCsLLMClient) CompleteStructured(_ context.Context, req contr
|
|||||||
if !ok {
|
if !ok {
|
||||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||||
}
|
}
|
||||||
*target = client.response
|
|
||||||
content := append([]byte(nil), client.content...)
|
content := append([]byte(nil), client.content...)
|
||||||
if len(content) == 0 {
|
if len(content) != 0 {
|
||||||
|
if err := json.Unmarshal(content, target); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*target = client.response
|
||||||
var err error
|
var err error
|
||||||
content, err = json.Marshal(client.response)
|
content, err = json.Marshal(client.response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -20,40 +20,33 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"caster": {
|
"caster": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
|
||||||
"description": "Canonical in-world character or creature that casts the spell, never the human player, transcript speaker, or GM when the in-world caster can be identified."
|
"description": "Canonical in-world character or creature that casts the spell, never the human player, transcript speaker, or GM when the in-world caster can be identified."
|
||||||
},
|
},
|
||||||
"spell": {
|
"spell": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
|
||||||
"description": "Canonical spell name from the provided spell-name catalog."
|
"description": "Canonical spell name from the provided spell-name catalog."
|
||||||
},
|
},
|
||||||
"effect": {
|
"effect": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
|
||||||
"description": "Concise immediate effect or resolution established by the cited transcript units; do not infer mechanics from general D&D rules knowledge or follow persistent downstream consequences."
|
"description": "Concise immediate effect or resolution established by the cited transcript units; do not infer mechanics from general D&D rules knowledge or follow persistent downstream consequences."
|
||||||
},
|
},
|
||||||
"narrative_description": {
|
"narrative_description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
|
||||||
"description": "Short session-grounded description of the casting declaration and immediate resolution, containing only details established by the cited transcript units."
|
"description": "Short session-grounded description of the casting declaration and immediate resolution, containing only details established by the cited transcript units."
|
||||||
},
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"description": "Transcript ranges offered as evidence for factual claims about the casting declaration and immediate resolution in this spell-cast object.",
|
||||||
"description": "One or more narrow transcript ranges that collectively support every factual claim about the casting declaration and immediate resolution in this spell-cast object.",
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["start_unit_id", "end_unit_id"],
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
},
|
},
|
||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer"
|
||||||
"minimum": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse) {
|
func canonicalizeResponse(response *extractionResponse) {
|
||||||
@@ -29,14 +28,10 @@ func canonicalizeResponse(response *extractionResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeSpellCast(spell *spellCastResponse) {
|
func canonicalizeSpellCast(spell *spellCastResponse) {
|
||||||
for index := range spell.SourceRefs {
|
|
||||||
spell.SourceRefs[index].StartUnitID = canonicalUnitRef(spell.SourceRefs[index].StartUnitID)
|
|
||||||
spell.SourceRefs[index].EndUnitID = canonicalUnitRef(spell.SourceRefs[index].EndUnitID)
|
|
||||||
}
|
|
||||||
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
||||||
left := spell.SourceRefs[i]
|
left := spell.SourceRefs[i]
|
||||||
right := spell.SourceRefs[j]
|
right := spell.SourceRefs[j]
|
||||||
if left.StartUnitID.Int() != right.StartUnitID.Int() {
|
if left.StartUnitID != right.StartUnitID {
|
||||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
||||||
}
|
}
|
||||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
||||||
@@ -44,14 +39,6 @@ func canonicalizeSpellCast(spell *spellCastResponse) {
|
|||||||
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
|
|
||||||
value := ref.Int()
|
|
||||||
if value <= 0 {
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
return shared.UnitRefFromInt(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
|
func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
|
||||||
if len(refs) < 2 {
|
if len(refs) < 2 {
|
||||||
return refs
|
return refs
|
||||||
@@ -69,13 +56,12 @@ func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
|
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
|
||||||
return left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
||||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||||
for _, ref := range spell.SourceRefs {
|
for _, ref := range spell.SourceRefs {
|
||||||
start := ref.StartUnitID.Int()
|
start := ref.StartUnitID
|
||||||
if start > 0 {
|
if start > 0 {
|
||||||
return start, true
|
return start, true
|
||||||
}
|
}
|
||||||
@@ -83,8 +69,7 @@ func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func unitSortValue(ref shared.UnitRef) int {
|
func unitSortValue(value int) int {
|
||||||
value := ref.Int()
|
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return int(^uint(0) >> 1)
|
return int(^uint(0) >> 1)
|
||||||
}
|
}
|
||||||
@@ -98,8 +83,8 @@ func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellL
|
|||||||
for refIndex, ref := range spell.SourceRefs {
|
for refIndex, ref := range spell.SourceRefs {
|
||||||
refs[refIndex] = source.SourceRef{
|
refs[refIndex] = source.SourceRef{
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
StartUnitID: ref.StartUnitID.Int(),
|
StartUnitID: ref.StartUnitID,
|
||||||
EndUnitID: ref.EndUnitID.Int(),
|
EndUnitID: ref.EndUnitID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
spellCasts[index] = dnd.SpellCast{
|
spellCasts[index] = dnd.SpellCast{
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
|||||||
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||||
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", SourceRefs: responseSourceRefs(2, 2)},
|
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", SourceRefs: responseSourceRefs(2, 2)},
|
||||||
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "First.", SourceRefs: []spellSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)}, {StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)}}},
|
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "First.", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
|
||||||
{Caster: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."},
|
{Caster: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."},
|
||||||
}}}
|
}}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
@@ -286,7 +286,7 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
|||||||
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||||
Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "Aria heals.",
|
Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "Aria heals.",
|
||||||
SourceRefs: []spellSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")}},
|
SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||||
}}}}
|
}}}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,3 +297,18 @@ func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
|||||||
t.Fatalf("source ref = %#v, want canonical source with invalid range preserved", ref)
|
t.Fatalf("source ref = %#v, want canonical source with invalid range preserved", ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{content: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","effect":"","narrative_description":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
spell := result.Value.SpellCasts[0]
|
||||||
|
if spell.Caster != "" || spell.Effect != "" || spell.NarrativeDescription != "" {
|
||||||
|
t.Fatalf("spell = %#v, want blank semantic values preserved", spell)
|
||||||
|
}
|
||||||
|
if refs := spell.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
|
||||||
|
t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package spells
|
package spells
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||||
}
|
}
|
||||||
@@ -15,6 +13,6 @@ type spellCastResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type spellSourceRefResponse struct {
|
type spellSourceRefResponse struct {
|
||||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
StartUnitID int `json:"start_unit_id"`
|
||||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
EndUnitID int `json:"end_unit_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,66 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
|
|||||||
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("valid private spells response rejected: %v", err)
|
t.Fatalf("valid private spells response rejected: %v", err)
|
||||||
}
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
response map[string]any
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "semantic blanks and empty evidence",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"caster": "", "spell": "", "effect": "", "narrative_description": "", "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonpositive unit candidates",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.",
|
||||||
|
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
|
||||||
|
}}},
|
||||||
|
valid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required field",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{}, "id": "assigned later",
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong field type",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"caster": 7, "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noninteger source identifier",
|
||||||
|
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||||
|
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.",
|
||||||
|
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
content, err := json.Marshal(test.response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = validateJSONSchema(content, schema.JSONSchema)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("validateJSONSchema() error = %v, want valid=%t", err, test.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema([]byte(`{"spell_casts":`), schema.JSONSchema); err == nil {
|
||||||
|
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
|
||||||
|
}
|
||||||
|
|
||||||
withCanonicalSourceID := validSpellsResponse()
|
withCanonicalSourceID := validSpellsResponse()
|
||||||
withCanonicalSourceID["spell_casts"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha"
|
withCanonicalSourceID["spell_casts"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
||||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,8 +75,8 @@ func mustJSON(t *testing.T, value any) string {
|
|||||||
func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
|
func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
|
||||||
return []spellSourceRefResponse{
|
return []spellSourceRefResponse{
|
||||||
{
|
{
|
||||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
StartUnitID: startUnitID,
|
||||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
EndUnitID: endUnitID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,9 +150,13 @@ func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req con
|
|||||||
if !ok {
|
if !ok {
|
||||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||||
}
|
}
|
||||||
*target = client.response
|
|
||||||
content := append([]byte(nil), client.content...)
|
content := append([]byte(nil), client.content...)
|
||||||
if len(content) == 0 {
|
if len(content) != 0 {
|
||||||
|
if err := json.Unmarshal(content, target); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*target = client.response
|
||||||
var err error
|
var err error
|
||||||
content, err = json.Marshal(client.response)
|
content, err = json.Marshal(client.response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
|
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||||
|
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||||
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
|
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||||
seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register"
|
seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register"
|
||||||
@@ -131,7 +132,7 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProductionNPCPipelineAttributesInvalidShapeBeforeSchemaValidation(t *testing.T) {
|
func TestProductionNPCPipelineRoutesSemanticCandidatesToDeterministicValidators(t *testing.T) {
|
||||||
registries := productionNPCRegistries(t)
|
registries := productionNPCRegistries(t)
|
||||||
effective, err := loadNPCPipelineConfig(t).Resolve(config.ResolveInput{
|
effective, err := loadNPCPipelineConfig(t).Resolve(config.ResolveInput{
|
||||||
PipelineID: "dnd-npcs-fixture",
|
PipelineID: "dnd-npcs-fixture",
|
||||||
@@ -140,23 +141,51 @@ func TestProductionNPCPipelineAttributesInvalidShapeBeforeSchemaValidation(t *te
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
client := &fakeNPCProductionLLMClient{response: npcProductionResponse{NPCs: []npcProductionRecord{{
|
for _, test := range []struct {
|
||||||
Name: "",
|
name string
|
||||||
Aliases: []string{},
|
response []byte
|
||||||
Description: "A participant.",
|
reasonCode string
|
||||||
Relationships: []npcProductionRelationship{},
|
validatorName string
|
||||||
SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 1}},
|
}{
|
||||||
}}}}
|
{
|
||||||
output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
name: "blank string",
|
||||||
if err != nil {
|
response: []byte(`{"npcs":[{"name":"","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||||
t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
|
reasonCode: npcshape.ReasonCode,
|
||||||
}
|
validatorName: npcshape.Key,
|
||||||
if len(client.requests) != 3 || len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
|
},
|
||||||
t.Fatalf("LLM requests = %d rejected = %#v normalized = %#v, want exhausted shape rejection", len(client.requests), output.Rejected, output.NormalizeOutputs)
|
{
|
||||||
}
|
name: "empty evidence",
|
||||||
rejection := output.Rejected[0]
|
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[]}]}`),
|
||||||
if rejection.ReasonCode != npcshape.ReasonCode || rejection.ValidatorName != npcshape.Key || rejection.AttemptCount != 3 {
|
reasonCode: npcshape.ReasonCode,
|
||||||
t.Fatalf("rejection = %#v, want exhausted NPC shape rejection", rejection)
|
validatorName: npcshape.Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonpositive unit candidate",
|
||||||
|
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
|
||||||
|
reasonCode: npcsourcerefs.ReasonCode,
|
||||||
|
validatorName: npcsourcerefs.Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit candidate",
|
||||||
|
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
|
||||||
|
reasonCode: npcsourcerefs.ReasonCode,
|
||||||
|
validatorName: npcsourcerefs.Key,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
client := &fakeNPCProductionLLMClient{rawResponses: [][]byte{test.response, test.response, test.response}}
|
||||||
|
output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 3 || len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
|
||||||
|
t.Fatalf("LLM requests = %d rejected = %#v normalized = %#v, want exhausted rejection", len(client.requests), output.Rejected, output.NormalizeOutputs)
|
||||||
|
}
|
||||||
|
rejection := output.Rejected[0]
|
||||||
|
if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 {
|
||||||
|
t.Fatalf("rejection = %#v, want exhausted %s rejection", rejection, test.validatorName)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,15 +212,26 @@ type npcProductionSourceRef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeNPCProductionLLMClient struct {
|
type fakeNPCProductionLLMClient struct {
|
||||||
response npcProductionResponse
|
response npcProductionResponse
|
||||||
requests []contracts.StructuredCompletionRequest
|
rawResponses [][]byte
|
||||||
|
requests []contracts.StructuredCompletionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||||
client.requests = append(client.requests, req)
|
client.requests = append(client.requests, req)
|
||||||
content, err := json.Marshal(client.response)
|
var content []byte
|
||||||
if err != nil {
|
if client.rawResponses != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
index := len(client.requests) - 1
|
||||||
|
if index >= len(client.rawResponses) {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake NPC response %d", index)
|
||||||
|
}
|
||||||
|
content = append([]byte(nil), client.rawResponses[index]...)
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
content, err = json.Marshal(client.response)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(content, out); err != nil {
|
if err := json.Unmarshal(content, out); err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate NPC structured target: %w", err)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate NPC structured target: %w", err)
|
||||||
|
|||||||
@@ -22,24 +22,35 @@ type spellCastResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeSpellsLLMClient struct {
|
type fakeSpellsLLMClient struct {
|
||||||
response extractionResponse
|
response extractionResponse
|
||||||
responses []extractionResponse
|
responses []extractionResponse
|
||||||
requests []contracts.StructuredCompletionRequest
|
rawResponses [][]byte
|
||||||
|
requests []contracts.StructuredCompletionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
||||||
response := client.response
|
var content []byte
|
||||||
if client.responses != nil {
|
if client.rawResponses != nil {
|
||||||
index := len(client.requests) - 1
|
index := len(client.requests) - 1
|
||||||
if index >= len(client.responses) {
|
if index >= len(client.rawResponses) {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
|
||||||
}
|
}
|
||||||
response = client.responses[index]
|
content = append([]byte(nil), client.rawResponses[index]...)
|
||||||
}
|
} else {
|
||||||
content, err := json.Marshal(response)
|
response := client.response
|
||||||
if err != nil {
|
if client.responses != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
index := len(client.requests) - 1
|
||||||
|
if index >= len(client.responses) {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
|
||||||
|
}
|
||||||
|
response = client.responses[index]
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
content, err = json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(content, out); err != nil {
|
if err := json.Unmarshal(content, out); err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured target: %w", err)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured target: %w", err)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||||
|
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProductionSpellPipelineAttributesInvalidShapeBeforeSchemaValidation(t *testing.T) {
|
func TestProductionSpellPipelineRoutesSemanticCandidatesToDeterministicValidators(t *testing.T) {
|
||||||
registries := productionNPCRegistries(t)
|
registries := productionNPCRegistries(t)
|
||||||
configValue := config.Default()
|
configValue := config.Default()
|
||||||
configValue.Pipelines["dnd-spells-shape"] = pipeline.PipelineProfile{
|
configValue.Pipelines["dnd-spells-shape"] = pipeline.PipelineProfile{
|
||||||
@@ -186,24 +187,51 @@ func TestProductionSpellPipelineAttributesInvalidShapeBeforeSchemaValidation(t *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
response := extractionResponse{SpellCasts: []spellCastResponse{{
|
for _, test := range []struct {
|
||||||
Caster: "Aria",
|
name string
|
||||||
Spell: "Cure Wounds",
|
response []byte
|
||||||
Effect: "",
|
reasonCode string
|
||||||
NarrativeDescription: "Aria casts the spell.",
|
validatorName string
|
||||||
SourceRefs: responseSourceRefs("spell-session", 1, 1),
|
}{
|
||||||
}}}
|
{
|
||||||
client := &fakeSpellsLLMClient{responses: []extractionResponse{response, response, response}}
|
name: "blank string",
|
||||||
output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: readDNDSpellsFixture(t)})
|
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||||
if err != nil {
|
reasonCode: spellshape.ReasonCode,
|
||||||
t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
|
validatorName: spellshape.Key,
|
||||||
}
|
},
|
||||||
if len(client.requests) != 3 || len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
|
{
|
||||||
t.Fatalf("LLM requests = %d rejected = %#v normalized = %#v, want exhausted shape rejection", len(client.requests), output.Rejected, output.NormalizeOutputs)
|
name: "empty evidence",
|
||||||
}
|
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[]}]}`),
|
||||||
rejection := output.Rejected[0]
|
reasonCode: spellshape.ReasonCode,
|
||||||
if rejection.ReasonCode != spellshape.ReasonCode || rejection.ValidatorName != spellshape.Key || rejection.AttemptCount != 3 {
|
validatorName: spellshape.Key,
|
||||||
t.Fatalf("rejection = %#v, want exhausted spell shape rejection", rejection)
|
},
|
||||||
|
{
|
||||||
|
name: "nonpositive unit candidate",
|
||||||
|
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
|
||||||
|
reasonCode: spellsourcerefs.ReasonCode,
|
||||||
|
validatorName: spellsourcerefs.Key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown unit candidate",
|
||||||
|
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
|
||||||
|
reasonCode: spellsourcerefs.ReasonCode,
|
||||||
|
validatorName: spellsourcerefs.Key,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{rawResponses: [][]byte{test.response, test.response, test.response}}
|
||||||
|
output, err := runPreparedPipeline(t, registries, effective.ResolvedPipeline, client, pipeline.RunInput{RawInput: readDNDSpellsFixture(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 3 || len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
|
||||||
|
t.Fatalf("LLM requests = %d rejected = %#v normalized = %#v, want exhausted rejection", len(client.requests), output.Rejected, output.NormalizeOutputs)
|
||||||
|
}
|
||||||
|
rejection := output.Rejected[0]
|
||||||
|
if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 {
|
||||||
|
t.Fatalf("rejection = %#v, want exhausted %s rejection", rejection, test.validatorName)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user