Minimize D&D NPC extraction contracts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user