Minimize D&D NPC extraction contracts

This commit is contained in:
2026-07-22 18:51:26 +00:00
parent 14991cf58b
commit a263a0840c
43 changed files with 486 additions and 1395 deletions

View File

@@ -1,11 +1,10 @@
For every NPC record, cite transcript units that support the canonical name,
every alias, the description, and every relationship.
For every NPC record, return only the observed display name and transcript
units that support that identity.
Descriptions must be short session records, not biographies, statistics,
alignment, motivations, or lore inferred from general D&D knowledge. Do not
summarize every action or follow a participant through unrelated scenes.
Relationships must be stated or directly demonstrated by cited transcript
units, not inferred from game lore.
Do not return descriptions, aliases, relationships, biographies, statistics,
alignment, motivations, encounter summaries, or lore inferred from general
D&D knowledge. Do not invent a label for an anonymous creature, crowd, or
generic role.
Return aliases and relationships as arrays, including empty arrays when there
are none. Preserve observed display spelling.
Preserve observed display spelling. Return at least one narrow source range for
every record.

View File

@@ -1,15 +1,12 @@
Extract a concise Dungeons & Dragons non-player-character registry from the
provided transcript.
Extract the individually identifiable Dungeons & Dragons non-player characters
established by the provided transcript and cite where each identity appears.
Include an in-world non-PC participant when the transcript establishes that it
appears, acts, speaks, or is materially discussed and gives it a proper name,
a stable alias or title, or an individually useful distinguishing description.
Include an in-world non-PC participant only when the transcript gives it a
proper name or a stable, individually distinguishing title or alias.
Exclude human players, transcript speakers, and the GM as out-of-world people,
player characters identified by the player or party references, incidental or
hypothetical name drops, corrected transcription mistakes, indistinguishable
crowds or groups, and temporary summoned creatures or spell effects without a
persistent individual identity.
Keep each description concise and limited to facts established by the
transcript. Include only explicitly supported aliases and relationships.
hypothetical name drops, corrected transcription mistakes, anonymous or
generic roles, indistinguishable crowds or groups, invented descriptive labels,
and temporary summoned creatures or spell effects without a persistent
individual identity.

View File

@@ -12,40 +12,12 @@
"additionalProperties": false,
"required": [
"name",
"aliases",
"description",
"relationships",
"source_refs"
],
"properties": {
"name": {
"type": "string"
},
"aliases": {
"type": "array",
"items": {
"type": "string"
}
},
"description": {
"type": "string"
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target", "relationship"],
"properties": {
"target": {
"type": "string"
},
"relationship": {
"type": "string"
}
}
}
},
"source_refs": {
"type": "array",
"items": {

View File

@@ -98,35 +98,14 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList
npcs := make([]dnd.NPC, len(response.NPCs))
for index, npc := range response.NPCs {
npcs[index] = dnd.NPC{
ID: identity.DeriveID(npc.Name),
Name: npc.Name,
Aliases: cloneStrings(npc.Aliases),
Description: npc.Description,
Relationships: cloneRelationships(npc.Relationships),
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
ID: identity.DeriveID(npc.Name),
Name: npc.Name,
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
}
}
return dnd.NPCList{NPCs: npcs}
}
func cloneStrings(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
func cloneRelationships(values []npcRelationshipResponse) []dnd.NPCRelationship {
if values == nil {
return nil
}
out := make([]dnd.NPCRelationship, len(values))
for index, value := range values {
out[index] = dnd.NPCRelationship{Target: value.Target, Relationship: value.Relationship}
}
return out
}
func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef {
if values == nil {
return nil

View File

@@ -16,13 +16,10 @@ import (
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{
Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.",
Relationships: []npcRelationshipResponse{{Target: "Mira Thorn", Relationship: "reports to"}},
SourceRefs: responseSourceRefs(3, 3),
Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
},
{
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
Name: "Mira Thorn",
SourceRefs: []npcSourceRefResponse{
{StartUnitID: 2, EndUnitID: 2},
{StartUnitID: 1, EndUnitID: 2},
@@ -36,8 +33,8 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
t.Fatalf("Extract() error = %v, want nil", err)
}
want := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "commands"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.", Relationships: []dnd.NPCRelationship{{Target: "Mira Thorn", Relationship: "reports to"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}}
if !reflect.DeepEqual(result.Value, want) {
t.Fatalf("Value = %#v, want %#v", result.Value, want)
@@ -58,11 +55,11 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{
Name: "Later NPC", Aliases: []string{}, Description: "Appears later.", Relationships: []npcRelationshipResponse{},
Name: "Later NPC",
SourceRefs: responseSourceRefs(10, 10),
},
{
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{},
Name: "Earlier NPC",
SourceRefs: []npcSourceRefResponse{
{StartUnitID: 50, EndUnitID: 50},
{StartUnitID: 100, EndUnitID: 100},
@@ -109,14 +106,14 @@ func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
Name: "", Aliases: nil, Description: "", Relationships: nil,
Name: "",
SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases != nil || result.Value.NPCs[0].Relationships != nil {
if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" {
t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value)
}
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 {
@@ -125,13 +122,13 @@ 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}]}]}`)}
client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","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 != "" {
if npc.ID != "" || npc.Name != "" {
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}) {

View File

@@ -5,16 +5,8 @@ type extractionResponse struct {
}
type npcResponse struct {
Name string `json:"name"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
Relationships []npcRelationshipResponse `json:"relationships"`
SourceRefs []npcSourceRefResponse `json:"source_refs"`
}
type npcRelationshipResponse struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
Name string `json:"name"`
SourceRefs []npcSourceRefResponse `json:"source_refs"`
}
type npcSourceRefResponse struct {

View File

@@ -18,7 +18,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
t.Fatalf("schema = %#v, want private NPC schema identity", schema)
}
valid := map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{},
"name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}}
validJSON, err := json.Marshal(valid)
@@ -36,14 +36,14 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
{
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{},
"name": "", "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{},
"name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
}}},
valid: true,
@@ -51,25 +51,25 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
{
name: "missing required field",
response: map[string]any{"npcs": []any{map[string]any{
"aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []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": "Mira Thorn", "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": 7, "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{},
"name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
}}},
},
@@ -89,7 +89,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
}
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",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}}
withIDJSON, err := json.Marshal(withID)