Simplify D&D combat turn extraction contract
This commit is contained in:
@@ -30,8 +30,6 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
Return the combat_turns array even when no combat turn is established. Return
|
||||
one or more actions for every turn. For turn_kind, use exactly one of: turn,
|
||||
reaction, legendary_action, lair_action, or other. For each action category,
|
||||
use exactly one of: attack, spell, movement, item, ability_check, saving_throw,
|
||||
condition, or other. Set round to null when the transcript does not state an
|
||||
explicit or unambiguous positive round number. Set resolution to null when the
|
||||
transcript establishes the declaration but not an immediate resolution.
|
||||
actor, turn_kind, and source_refs for every record. For turn_kind, use exactly
|
||||
one of: turn, reaction, legendary_action, lair_action, or other. Cite the
|
||||
transcript ranges that establish both the actor and the combat event. Use the
|
||||
players, party, and transcript context to map speakers to in-world actors. NPC
|
||||
names may help disambiguate identity but do not replace transcript evidence.
|
||||
|
||||
@@ -2,18 +2,17 @@ Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript.
|
||||
|
||||
Include a record only when the transcript establishes that an in-world
|
||||
participant takes a combat turn or performs a discrete interrupting combat
|
||||
event. Reactions, legendary actions, lair actions, and other out-of-turn events
|
||||
belong at the point where they occur in transcript chronology.
|
||||
event. Interrupting events belong at the point where they occur in transcript
|
||||
chronology.
|
||||
|
||||
Exclude initiative setup without a turn or combat event, tactical planning,
|
||||
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
||||
of combat outside the current passage, and downstream consequences.
|
||||
table talk, rules lookup, hypothetical events, abandoned intentions, recaps
|
||||
outside the current passage, and downstream consequences.
|
||||
|
||||
Do not infer a round, target, roll, amount, condition, outcome, or action
|
||||
classification from D&D rules knowledge. Preserve the session as played;
|
||||
attribute relevant nonstandard rulings to the GM or table.
|
||||
Do not infer combat events from D&D rules knowledge. Preserve the session as
|
||||
played and attribute relevant nonstandard rulings to the GM or table.
|
||||
|
||||
Unmatched actors and targets remain permitted.
|
||||
Unmatched actors remain permitted.
|
||||
|
||||
Place all supporting transcript ranges for a turn in its turn-level source_refs
|
||||
collection.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||
"required": ["actor", "turn_kind", "source_refs"],
|
||||
"properties": {
|
||||
"actor": {
|
||||
"type": "string"
|
||||
@@ -18,37 +18,6 @@
|
||||
"turn_kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"round": {
|
||||
"type": ["integer", "null"]
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["category", "declaration", "targets", "resolution"],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"declaration": {
|
||||
"type": "string"
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -96,9 +96,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
|
||||
turns[index] = dnd.CombatTurn{
|
||||
Actor: turn.Actor,
|
||||
TurnKind: dnd.CombatTurnKind(turn.TurnKind),
|
||||
Round: cloneIntPointer(turn.Round),
|
||||
Actions: canonicalActions(turn.Actions),
|
||||
Summary: turn.Summary,
|
||||
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
|
||||
}
|
||||
}
|
||||
@@ -108,22 +105,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
|
||||
return dnd.CombatTurnList{CombatTurns: turns}
|
||||
}
|
||||
|
||||
func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
|
||||
if actions == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]dnd.CombatAction, len(actions))
|
||||
for index, action := range actions {
|
||||
out[index] = dnd.CombatAction{
|
||||
Category: dnd.CombatActionCategory(action.Category),
|
||||
Declaration: action.Declaration,
|
||||
Targets: append([]string(nil), action.Targets...),
|
||||
Resolution: cloneStringPointer(action.Resolution),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
if refs == nil {
|
||||
return nil
|
||||
@@ -134,19 +115,3 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneIntPointer(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
out := *value
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneStringPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
out := *value
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
slots = append(slots, contracts.ReferenceSlot{
|
||||
Name: NPCRegistryReferenceSlot,
|
||||
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
|
||||
Description: "Optional normalized NPC registry used for canonical actor grounding.",
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: NPCRegistryMaxBytes,
|
||||
|
||||
@@ -17,26 +17,20 @@ import (
|
||||
)
|
||||
|
||||
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
round := 3
|
||||
resolution := "The ogre falls back."
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||
{
|
||||
Actor: "Borin", TurnKind: "turn", Round: &round,
|
||||
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
|
||||
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
|
||||
Actor: "Borin", TurnKind: "turn",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
|
||||
},
|
||||
{
|
||||
Actor: "Aria", TurnKind: "reaction", Round: nil,
|
||||
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
|
||||
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
|
||||
Actor: "Aria", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Actor: "Unknown", TurnKind: "other", Round: nil,
|
||||
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
|
||||
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
|
||||
Actor: "Unknown", TurnKind: "other",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
|
||||
},
|
||||
}}}
|
||||
|
||||
@@ -51,9 +45,6 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
|
||||
t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
|
||||
}
|
||||
if result.Value.CombatTurns[0].Round != nil || result.Value.CombatTurns[0].Actions[0].Resolution == nil || *result.Value.CombatTurns[0].Actions[0].Resolution != resolution {
|
||||
t.Fatalf("nullable fields = %#v, want nil round and preserved resolution", result.Value.CombatTurns[0])
|
||||
}
|
||||
if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) {
|
||||
t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref)
|
||||
}
|
||||
@@ -72,13 +63,10 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||
negativeRound := -1
|
||||
emptyResolution := " "
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||
{
|
||||
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
|
||||
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
|
||||
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
Actor: " ", TurnKind: "unsupported",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
},
|
||||
}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
@@ -86,12 +74,9 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
|
||||
}
|
||||
turn := result.Value.CombatTurns[0]
|
||||
if turn.Actor != " " || turn.TurnKind != "unsupported" || turn.Round == nil || *turn.Round != negativeRound || turn.Summary != " " {
|
||||
if turn.Actor != " " || turn.TurnKind != "unsupported" {
|
||||
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
|
||||
}
|
||||
if turn.Actions == nil || turn.Actions[0].Targets != nil || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != emptyResolution {
|
||||
t.Fatalf("invalid action fields = %#v, want preserved candidate values", turn.Actions[0])
|
||||
}
|
||||
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
|
||||
t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0])
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package combatturns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type extractionResponse struct {
|
||||
CombatTurns []combatTurnResponse `json:"combat_turns"`
|
||||
}
|
||||
@@ -13,93 +7,10 @@ type extractionResponse struct {
|
||||
type combatTurnResponse struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind string `json:"turn_kind"`
|
||||
Round *int `json:"round"`
|
||||
Actions []combatActionResponse `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
SourceRefs []combatSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type combatActionResponse struct {
|
||||
Category string `json:"category"`
|
||||
Declaration string `json:"declaration"`
|
||||
Targets []string `json:"targets"`
|
||||
Resolution *string `json:"resolution"`
|
||||
}
|
||||
|
||||
type combatSourceRefResponse struct {
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
|
||||
func (response *combatTurnResponse) UnmarshalJSON(content []byte) error {
|
||||
type responseWire struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind string `json:"turn_kind"`
|
||||
Round json.RawMessage `json:"round"`
|
||||
Actions []combatActionResponse `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
SourceRefs []combatSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
var wire responseWire
|
||||
if err := json.Unmarshal(content, &wire); err != nil {
|
||||
return err
|
||||
}
|
||||
round, err := decodeRequiredNullableInt(wire.Round, "round")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*response = combatTurnResponse{
|
||||
Actor: wire.Actor, TurnKind: wire.TurnKind, Round: round, Actions: wire.Actions,
|
||||
Summary: wire.Summary, SourceRefs: wire.SourceRefs,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (response *combatActionResponse) UnmarshalJSON(content []byte) error {
|
||||
type responseWire struct {
|
||||
Category string `json:"category"`
|
||||
Declaration string `json:"declaration"`
|
||||
Targets []string `json:"targets"`
|
||||
Resolution json.RawMessage `json:"resolution"`
|
||||
}
|
||||
var wire responseWire
|
||||
if err := json.Unmarshal(content, &wire); err != nil {
|
||||
return err
|
||||
}
|
||||
resolution, err := decodeRequiredNullableString(wire.Resolution, "resolution")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*response = combatActionResponse{
|
||||
Category: wire.Category, Declaration: wire.Declaration, Targets: wire.Targets, Resolution: resolution,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRequiredNullableInt(raw json.RawMessage, field string) (*int, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%s must be present", field)
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func decodeRequiredNullableString(raw json.RawMessage, field string) (*string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%s must be present", field)
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
@@ -2,57 +2,20 @@ package combatturns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) {
|
||||
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","round":-1,"actions":[{"category":"unsupported","declaration":"","targets":[],"resolution":""}],"summary":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||
var response extractionResponse
|
||||
if err := json.Unmarshal(content, &response); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v, want semantic candidate", err)
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
turn := response.CombatTurns[0]
|
||||
if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" {
|
||||
t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn)
|
||||
if turn.Actor != "" || turn.TurnKind != "unsupported" {
|
||||
t.Fatalf("decoded turn = %#v", turn)
|
||||
}
|
||||
if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", turn.SourceRefs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionResponseDecodingDistinguishesMissingAndNullNullableFields(t *testing.T) {
|
||||
validNulls := []byte(`{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[],"resolution":null}],"summary":"attacks","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||
var response extractionResponse
|
||||
if err := json.Unmarshal(validNulls, &response); err != nil {
|
||||
t.Fatalf("json.Unmarshal(nulls) error = %v", err)
|
||||
}
|
||||
if response.CombatTurns[0].Round != nil || response.CombatTurns[0].Actions[0].Resolution != nil {
|
||||
t.Fatalf("decoded nullables = %#v, want explicit null", response.CombatTurns[0])
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
content string
|
||||
field string
|
||||
}{
|
||||
{
|
||||
name: "missing round",
|
||||
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","actions":[],"summary":"attacks","source_refs":[]}]}`,
|
||||
field: "round",
|
||||
},
|
||||
{
|
||||
name: "missing resolution",
|
||||
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[]}],"summary":"attacks","source_refs":[]}]}`,
|
||||
field: "resolution",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var candidate extractionResponse
|
||||
err := json.Unmarshal([]byte(test.content), &candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.field) {
|
||||
t.Fatalf("json.Unmarshal() error = %v, want missing %s failure", err, test.field)
|
||||
}
|
||||
})
|
||||
t.Fatalf("decoded ref = %#v", turn.SourceRefs[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
||||
turn["actor"] = ""
|
||||
turn["turn_kind"] = "unsupported"
|
||||
turn["round"] = -1
|
||||
turn["summary"] = ""
|
||||
action := turn["actions"].([]any)[0].(map[string]any)
|
||||
action["category"] = "unsupported"
|
||||
action["declaration"] = ""
|
||||
action["targets"] = []any{""}
|
||||
action["resolution"] = ""
|
||||
ref := turn["source_refs"].([]any)[0].(map[string]any)
|
||||
ref["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
@@ -63,7 +56,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
|
||||
}
|
||||
turn["actions"] = []any{}
|
||||
turn["source_refs"] = []any{}
|
||||
content, err = json.Marshal(semanticCandidate)
|
||||
if err != nil {
|
||||
@@ -83,12 +75,10 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }},
|
||||
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }},
|
||||
{name: "missing actor", mutate: func(turn map[string]any) { delete(turn, "actor") }},
|
||||
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
|
||||
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
|
||||
{name: "missing nullable resolution", mutate: func(turn map[string]any) {
|
||||
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
|
||||
}},
|
||||
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validCombatResponse()
|
||||
@@ -134,11 +124,8 @@ func validCombatResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"combat_turns": []any{
|
||||
map[string]any{
|
||||
"actor": "Aria", "turn_kind": "reaction", "round": nil,
|
||||
"actions": []any{map[string]any{
|
||||
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
|
||||
}},
|
||||
"summary": "Aria reacts.",
|
||||
"actor": "Aria",
|
||||
"turn_kind": "reaction",
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user