feat: plan deterministic generated conversations
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
class_name ConversationAct
|
||||
extends RefCounted
|
||||
|
||||
var _act_id: StringName
|
||||
var _intent_id: StringName
|
||||
var _speaker: WorldEntityRef
|
||||
var _listener: WorldEntityRef
|
||||
var _causal_topic_ids: Array[StringName] = []
|
||||
var _reason_trace: Array[StringName] = []
|
||||
|
||||
|
||||
func _init(
|
||||
act_id: StringName = &"",
|
||||
intent_id: StringName = &"",
|
||||
speaker: WorldEntityRef = null,
|
||||
listener: WorldEntityRef = null,
|
||||
causal_topic_ids: Array[StringName] = [],
|
||||
reason_trace: Array[StringName] = []
|
||||
) -> void:
|
||||
_act_id = act_id
|
||||
_intent_id = intent_id
|
||||
_speaker = ConversationSemantic.duplicate_entity(speaker)
|
||||
_listener = ConversationSemantic.duplicate_entity(listener)
|
||||
_causal_topic_ids = causal_topic_ids.duplicate()
|
||||
_reason_trace = reason_trace.duplicate()
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return (
|
||||
ConversationSemantic.is_valid_id(_act_id)
|
||||
and ConversationSemantic.is_valid_id(_intent_id)
|
||||
and _speaker != null
|
||||
and _speaker.is_valid()
|
||||
and _listener != null
|
||||
and _listener.is_valid()
|
||||
and not _speaker.equals(_listener)
|
||||
and ConversationSemantic.normalize_id_array(_causal_topic_ids) != null
|
||||
and ConversationSemantic.normalize_trace(_reason_trace) != null
|
||||
)
|
||||
|
||||
|
||||
func get_act_id() -> StringName:
|
||||
return _act_id
|
||||
|
||||
|
||||
func get_intent_id() -> StringName:
|
||||
return _intent_id
|
||||
|
||||
|
||||
func get_speaker() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_speaker)
|
||||
|
||||
|
||||
func get_listener() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_listener)
|
||||
|
||||
|
||||
func get_causal_topic_ids() -> Array[StringName]:
|
||||
return _causal_topic_ids.duplicate()
|
||||
|
||||
|
||||
func get_reason_trace() -> Array[StringName]:
|
||||
return _reason_trace.duplicate()
|
||||
|
||||
|
||||
func copy() -> ConversationAct:
|
||||
return ConversationAct.new(
|
||||
_act_id, _intent_id, _speaker, _listener, _causal_topic_ids, _reason_trace
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsddan1v334nb
|
||||
@@ -0,0 +1,70 @@
|
||||
class_name ConversationIntentCatalog
|
||||
extends Resource
|
||||
|
||||
@export var intents: Array[ConversationIntentDefinition] = []
|
||||
|
||||
var _intents_by_id: Dictionary = {}
|
||||
var _errors: Array[String] = []
|
||||
|
||||
|
||||
func rebuild() -> Array[String]:
|
||||
_intents_by_id.clear()
|
||||
_errors.clear()
|
||||
for definition in intents:
|
||||
if definition == null:
|
||||
_errors.append("Intent catalog contains a null definition")
|
||||
continue
|
||||
for error in definition.validate():
|
||||
_errors.append("Intent '%s': %s" % [definition.intent_id, error])
|
||||
if _intents_by_id.has(definition.intent_id):
|
||||
_errors.append("Duplicate intent '%s'" % definition.intent_id)
|
||||
else:
|
||||
_intents_by_id[definition.intent_id] = definition
|
||||
for definition in intents:
|
||||
if definition == null:
|
||||
continue
|
||||
for response_intent_id in definition.response_intent_ids:
|
||||
if not _intents_by_id.has(response_intent_id):
|
||||
_errors.append(
|
||||
(
|
||||
"Intent '%s' references unknown response '%s'"
|
||||
% [definition.intent_id, response_intent_id]
|
||||
)
|
||||
)
|
||||
_errors.sort()
|
||||
return get_errors()
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return rebuild().is_empty()
|
||||
|
||||
|
||||
func get_errors() -> Array[String]:
|
||||
return _errors.duplicate()
|
||||
|
||||
|
||||
func has_intent(intent_id: StringName) -> bool:
|
||||
_ensure_built()
|
||||
return _intents_by_id.has(intent_id)
|
||||
|
||||
|
||||
func get_response_intent_ids(intent_id: StringName) -> Array[StringName]:
|
||||
_ensure_built()
|
||||
var definition := _intents_by_id.get(intent_id) as ConversationIntentDefinition
|
||||
return definition.response_intent_ids.duplicate() if definition != null else []
|
||||
|
||||
|
||||
func get_intent_ids() -> Array[StringName]:
|
||||
_ensure_built()
|
||||
var result: Array[StringName] = []
|
||||
for intent_id in _intents_by_id:
|
||||
result.append(intent_id)
|
||||
result.sort_custom(
|
||||
func(left: StringName, right: StringName) -> bool: return String(left) < String(right)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
func _ensure_built() -> void:
|
||||
if _intents_by_id.is_empty() and not intents.is_empty():
|
||||
rebuild()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1mqen7pdhv0m
|
||||
@@ -0,0 +1,22 @@
|
||||
class_name ConversationIntentDefinition
|
||||
extends Resource
|
||||
|
||||
@export var intent_id: StringName
|
||||
@export var response_intent_ids: Array[StringName] = []
|
||||
|
||||
|
||||
func validate() -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
if not ConversationSemantic.is_valid_id(intent_id):
|
||||
errors.append("intent_id is invalid")
|
||||
var seen: Dictionary = {}
|
||||
for response_intent_id in response_intent_ids:
|
||||
if not ConversationSemantic.is_valid_id(response_intent_id):
|
||||
errors.append("response intent ID is invalid for '%s'" % intent_id)
|
||||
elif seen.has(response_intent_id):
|
||||
errors.append(
|
||||
"duplicate response intent '%s' for '%s'" % [response_intent_id, intent_id]
|
||||
)
|
||||
else:
|
||||
seen[response_intent_id] = true
|
||||
return errors
|
||||
@@ -0,0 +1 @@
|
||||
uid://40cns84o6dj5
|
||||
@@ -0,0 +1,42 @@
|
||||
class_name ConversationIntentIds
|
||||
extends RefCounted
|
||||
|
||||
const GREET := &"greet"
|
||||
const ASK_WORK := &"ask_work"
|
||||
const ASK_WELLBEING := &"ask_wellbeing"
|
||||
const ASK_WHAT_HAPPENED := &"ask_what_happened"
|
||||
const SHARE_FACT := &"share_fact"
|
||||
const DESCRIBE_NEED := &"describe_need"
|
||||
const ASK_WHO_ELSE := &"ask_who_else"
|
||||
const OFFER_HELP := &"offer_help"
|
||||
const ACCEPT := &"accept"
|
||||
const DECLINE := &"decline"
|
||||
const REPORT_PROGRESS := &"report_progress"
|
||||
const RENEGOTIATE := &"renegotiate"
|
||||
const THANK := &"thank"
|
||||
const REPROACH := &"reproach"
|
||||
const ACKNOWLEDGE_SUPERSESSION := &"acknowledge_supersession"
|
||||
const GOODBYE := &"goodbye"
|
||||
|
||||
const ALL: Array[StringName] = [
|
||||
GREET,
|
||||
ASK_WORK,
|
||||
ASK_WELLBEING,
|
||||
ASK_WHAT_HAPPENED,
|
||||
SHARE_FACT,
|
||||
DESCRIBE_NEED,
|
||||
ASK_WHO_ELSE,
|
||||
OFFER_HELP,
|
||||
ACCEPT,
|
||||
DECLINE,
|
||||
REPORT_PROGRESS,
|
||||
RENEGOTIATE,
|
||||
THANK,
|
||||
REPROACH,
|
||||
ACKNOWLEDGE_SUPERSESSION,
|
||||
GOODBYE,
|
||||
]
|
||||
|
||||
|
||||
static func is_supported(intent_id: StringName) -> bool:
|
||||
return intent_id in ALL
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4f6plcdw77xe
|
||||
@@ -0,0 +1,91 @@
|
||||
class_name ConversationOption
|
||||
extends RefCounted
|
||||
|
||||
var _option_id: StringName
|
||||
var _intent_id: StringName
|
||||
var _speaker: WorldEntityRef
|
||||
var _listener: WorldEntityRef
|
||||
var _causal_topic_ids: Array[StringName] = []
|
||||
var _enabled := true
|
||||
var _reason_code: StringName
|
||||
var _reason_trace: Array[StringName] = []
|
||||
|
||||
|
||||
func _init(
|
||||
option_id: StringName = &"",
|
||||
intent_id: StringName = &"",
|
||||
speaker: WorldEntityRef = null,
|
||||
listener: WorldEntityRef = null,
|
||||
causal_topic_ids: Array[StringName] = [],
|
||||
enabled: bool = true,
|
||||
reason_code: StringName = &"",
|
||||
reason_trace: Array[StringName] = []
|
||||
) -> void:
|
||||
_option_id = option_id
|
||||
_intent_id = intent_id
|
||||
_speaker = ConversationSemantic.duplicate_entity(speaker)
|
||||
_listener = ConversationSemantic.duplicate_entity(listener)
|
||||
_causal_topic_ids = causal_topic_ids.duplicate()
|
||||
_enabled = enabled
|
||||
_reason_code = reason_code
|
||||
_reason_trace = reason_trace.duplicate()
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return (
|
||||
ConversationSemantic.is_valid_id(_option_id)
|
||||
and ConversationSemantic.is_valid_id(_intent_id)
|
||||
and _speaker != null
|
||||
and _speaker.is_valid()
|
||||
and _listener != null
|
||||
and _listener.is_valid()
|
||||
and not _speaker.equals(_listener)
|
||||
and ConversationSemantic.normalize_id_array(_causal_topic_ids) != null
|
||||
and (_enabled or ConversationSemantic.is_valid_id(_reason_code))
|
||||
and ConversationSemantic.normalize_trace(_reason_trace) != null
|
||||
)
|
||||
|
||||
|
||||
func get_option_id() -> StringName:
|
||||
return _option_id
|
||||
|
||||
|
||||
func get_intent_id() -> StringName:
|
||||
return _intent_id
|
||||
|
||||
|
||||
func get_speaker() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_speaker)
|
||||
|
||||
|
||||
func get_listener() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_listener)
|
||||
|
||||
|
||||
func get_causal_topic_ids() -> Array[StringName]:
|
||||
return _causal_topic_ids.duplicate()
|
||||
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return _enabled
|
||||
|
||||
|
||||
func get_reason_code() -> StringName:
|
||||
return _reason_code
|
||||
|
||||
|
||||
func get_reason_trace() -> Array[StringName]:
|
||||
return _reason_trace.duplicate()
|
||||
|
||||
|
||||
func copy() -> ConversationOption:
|
||||
return ConversationOption.new(
|
||||
_option_id,
|
||||
_intent_id,
|
||||
_speaker,
|
||||
_listener,
|
||||
_causal_topic_ids,
|
||||
_enabled,
|
||||
_reason_code,
|
||||
_reason_trace
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cx7fllak35woy
|
||||
@@ -0,0 +1,8 @@
|
||||
class_name ConversationPresenter
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# Presentation consumes a completed semantic plan. It must not select options or
|
||||
# mutate simulation state while rendering that plan.
|
||||
func present_turn(_turn: ConversationTurn, _presentation_context: Dictionary = {}) -> Variant:
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://5d4pi5tu3bjk
|
||||
@@ -0,0 +1,56 @@
|
||||
class_name ConversationSelectionResult
|
||||
extends RefCounted
|
||||
|
||||
var _accepted := false
|
||||
var _reason_code: StringName
|
||||
var _expected_revision := -1
|
||||
var _current_revision := -1
|
||||
var _selected_option_id: StringName
|
||||
var _turn: ConversationTurn
|
||||
var _reason_trace: Array[StringName] = []
|
||||
|
||||
|
||||
func _init(
|
||||
accepted: bool = false,
|
||||
reason_code: StringName = &"",
|
||||
expected_revision: int = -1,
|
||||
current_revision: int = -1,
|
||||
selected_option_id: StringName = &"",
|
||||
turn: ConversationTurn = null,
|
||||
reason_trace: Array[StringName] = []
|
||||
) -> void:
|
||||
_accepted = accepted
|
||||
_reason_code = reason_code
|
||||
_expected_revision = expected_revision
|
||||
_current_revision = current_revision
|
||||
_selected_option_id = selected_option_id
|
||||
_turn = turn.copy() if turn != null else null
|
||||
_reason_trace = reason_trace.duplicate()
|
||||
|
||||
|
||||
func was_accepted() -> bool:
|
||||
return _accepted
|
||||
|
||||
|
||||
func get_reason_code() -> StringName:
|
||||
return _reason_code
|
||||
|
||||
|
||||
func get_expected_revision() -> int:
|
||||
return _expected_revision
|
||||
|
||||
|
||||
func get_current_revision() -> int:
|
||||
return _current_revision
|
||||
|
||||
|
||||
func get_selected_option_id() -> StringName:
|
||||
return _selected_option_id
|
||||
|
||||
|
||||
func get_turn() -> ConversationTurn:
|
||||
return _turn.copy() if _turn != null else null
|
||||
|
||||
|
||||
func get_reason_trace() -> Array[StringName]:
|
||||
return _reason_trace.duplicate()
|
||||
@@ -0,0 +1 @@
|
||||
uid://uvl0i6lfcs3s
|
||||
@@ -0,0 +1,56 @@
|
||||
class_name ConversationSemantic
|
||||
extends RefCounted
|
||||
|
||||
const MAX_ID_LENGTH := 96
|
||||
const MAX_TRACE_SIZE := 32
|
||||
|
||||
|
||||
static func is_valid_id(value: Variant) -> bool:
|
||||
if value is not String and value is not StringName:
|
||||
return false
|
||||
var text := String(value)
|
||||
if text.is_empty() or text.length() > MAX_ID_LENGTH:
|
||||
return false
|
||||
for character in text:
|
||||
if not _is_safe_id_character(character):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func normalize_id_array(value: Variant, allow_empty: bool = true) -> Variant:
|
||||
if value is not Array and value is not PackedStringArray:
|
||||
return null
|
||||
var normalized: Array[StringName] = []
|
||||
var seen: Dictionary = {}
|
||||
for item in value:
|
||||
if not is_valid_id(item):
|
||||
return null
|
||||
var semantic_id := StringName(item)
|
||||
if seen.has(semantic_id):
|
||||
continue
|
||||
seen[semantic_id] = true
|
||||
normalized.append(semantic_id)
|
||||
if not allow_empty and normalized.is_empty():
|
||||
return null
|
||||
return normalized
|
||||
|
||||
|
||||
static func normalize_trace(value: Variant) -> Variant:
|
||||
var normalized: Variant = normalize_id_array(value)
|
||||
if normalized == null or normalized.size() > MAX_TRACE_SIZE:
|
||||
return null
|
||||
return normalized
|
||||
|
||||
|
||||
static func duplicate_entity(entity: WorldEntityRef) -> WorldEntityRef:
|
||||
return entity.duplicate_ref() if entity != null else null
|
||||
|
||||
|
||||
static func _is_safe_id_character(character: String) -> bool:
|
||||
var code := character.unicode_at(0)
|
||||
return (
|
||||
(code >= 48 and code <= 57)
|
||||
or (code >= 65 and code <= 90)
|
||||
or (code >= 97 and code <= 122)
|
||||
or character in ["_", "-", ".", ":"]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8ijyl3xk1cdi
|
||||
@@ -0,0 +1,418 @@
|
||||
class_name ConversationService
|
||||
extends RefCounted
|
||||
|
||||
signal conversation_invalidated(conversation_id: StringName, reason_code: StringName)
|
||||
|
||||
const DEFAULT_CATALOG_PATH := "res://simulation/dialogue/resources/jajce_conversation_intents.tres"
|
||||
const REASON_ACCEPTED := &"accepted"
|
||||
const REASON_CONVERSATION_NOT_FOUND := &"conversation_not_found"
|
||||
const REASON_CONVERSATION_ENDED := &"conversation_ended"
|
||||
const REASON_INVALID_CONTEXT := &"invalid_context"
|
||||
const REASON_INVALID_PARTICIPANTS := &"invalid_participants"
|
||||
const REASON_STALE_REVISION := &"stale_revision"
|
||||
const REASON_OPTION_NOT_FOUND := &"option_not_found"
|
||||
const REASON_OPTION_DISABLED := &"option_disabled"
|
||||
const REASON_INVALIDATED := &"invalidated"
|
||||
|
||||
const CONTEXT_KEYS := [
|
||||
"conversation_key",
|
||||
"initial_intent_id",
|
||||
"topic_ids",
|
||||
"option_intent_ids",
|
||||
"topics_by_intent",
|
||||
"blocked_intent_ids",
|
||||
"block_reason_by_intent",
|
||||
"reason_trace",
|
||||
]
|
||||
|
||||
var last_error := ""
|
||||
|
||||
var _catalog: ConversationIntentCatalog
|
||||
var _records_by_id: Dictionary = {}
|
||||
var _next_sequence := 1
|
||||
|
||||
|
||||
func _init(intent_catalog: ConversationIntentCatalog = null) -> void:
|
||||
_catalog = intent_catalog
|
||||
if _catalog == null:
|
||||
_catalog = load(DEFAULT_CATALOG_PATH) as ConversationIntentCatalog
|
||||
if _catalog == null:
|
||||
last_error = "Conversation intent catalog could not be loaded"
|
||||
elif not _catalog.rebuild().is_empty():
|
||||
last_error = "; ".join(_catalog.get_errors())
|
||||
|
||||
|
||||
func begin(
|
||||
speaker: WorldEntityRef, listener: WorldEntityRef, context: Dictionary = {}
|
||||
) -> StringName:
|
||||
last_error = ""
|
||||
if not _participants_are_valid(speaker, listener):
|
||||
last_error = "Conversation participants are invalid"
|
||||
return &""
|
||||
var normalized: Variant = _normalize_context(context)
|
||||
if normalized == null:
|
||||
last_error = "Conversation context is invalid"
|
||||
return &""
|
||||
var conversation_id := _make_conversation_id(speaker, listener, normalized)
|
||||
var initial_intent_id := StringName(normalized["initial_intent_id"])
|
||||
var revision := 1
|
||||
var turn := _plan_turn(
|
||||
conversation_id, revision, speaker, listener, initial_intent_id, normalized, &"began"
|
||||
)
|
||||
if turn == null:
|
||||
last_error = "Initial conversation turn could not be planned"
|
||||
return &""
|
||||
_records_by_id[conversation_id] = {
|
||||
"speaker": speaker.duplicate_ref(),
|
||||
"listener": listener.duplicate_ref(),
|
||||
"context": normalized,
|
||||
"revision": revision,
|
||||
"turn": turn,
|
||||
"ended": false,
|
||||
"invalid_reason": &"",
|
||||
}
|
||||
return conversation_id
|
||||
|
||||
|
||||
func get_turn(conversation_id: StringName) -> ConversationTurn:
|
||||
var record: Dictionary = _records_by_id.get(conversation_id, {})
|
||||
if record.is_empty() or bool(record["ended"]):
|
||||
return null
|
||||
var turn := record["turn"] as ConversationTurn
|
||||
return turn.copy() if turn != null else null
|
||||
|
||||
|
||||
func select_option(
|
||||
conversation_id: StringName, option_id: StringName, expected_revision: int
|
||||
) -> ConversationSelectionResult:
|
||||
var record: Dictionary = _records_by_id.get(conversation_id, {})
|
||||
if record.is_empty():
|
||||
return _rejected(
|
||||
REASON_CONVERSATION_NOT_FOUND,
|
||||
expected_revision,
|
||||
-1,
|
||||
option_id,
|
||||
null,
|
||||
[&"conversation_missing"]
|
||||
)
|
||||
var current_revision := int(record["revision"])
|
||||
var current_turn := record["turn"] as ConversationTurn
|
||||
if bool(record["ended"]):
|
||||
var reason := StringName(record["invalid_reason"])
|
||||
return _rejected(
|
||||
reason if not reason.is_empty() else REASON_CONVERSATION_ENDED,
|
||||
expected_revision,
|
||||
current_revision,
|
||||
option_id,
|
||||
null,
|
||||
[&"conversation_closed"]
|
||||
)
|
||||
if expected_revision != current_revision:
|
||||
return _rejected(
|
||||
REASON_STALE_REVISION,
|
||||
expected_revision,
|
||||
current_revision,
|
||||
option_id,
|
||||
current_turn,
|
||||
[&"revision_mismatch", &"revalidation_required"]
|
||||
)
|
||||
var option := current_turn.get_option(option_id)
|
||||
if option == null:
|
||||
return _rejected(
|
||||
REASON_OPTION_NOT_FOUND,
|
||||
expected_revision,
|
||||
current_revision,
|
||||
option_id,
|
||||
current_turn,
|
||||
[&"option_missing", &"revalidation_required"]
|
||||
)
|
||||
if not option.is_enabled():
|
||||
return _rejected(
|
||||
REASON_OPTION_DISABLED,
|
||||
expected_revision,
|
||||
current_revision,
|
||||
option_id,
|
||||
current_turn,
|
||||
option.get_reason_trace()
|
||||
)
|
||||
|
||||
var next_revision := current_revision + 1
|
||||
var next_turn := _plan_turn(
|
||||
conversation_id,
|
||||
next_revision,
|
||||
record["listener"] as WorldEntityRef,
|
||||
record["speaker"] as WorldEntityRef,
|
||||
option.get_intent_id(),
|
||||
record["context"],
|
||||
&"option_selected"
|
||||
)
|
||||
if next_turn == null:
|
||||
return _rejected(
|
||||
REASON_INVALID_CONTEXT,
|
||||
expected_revision,
|
||||
current_revision,
|
||||
option_id,
|
||||
current_turn,
|
||||
[&"next_turn_unplannable"]
|
||||
)
|
||||
record["revision"] = next_revision
|
||||
record["turn"] = next_turn
|
||||
record["speaker"] = next_turn.get_speaker()
|
||||
record["listener"] = next_turn.get_listener()
|
||||
_records_by_id[conversation_id] = record
|
||||
return ConversationSelectionResult.new(
|
||||
true,
|
||||
REASON_ACCEPTED,
|
||||
expected_revision,
|
||||
next_revision,
|
||||
option_id,
|
||||
next_turn,
|
||||
[&"revision_matched", &"option_enabled", &"turn_advanced"]
|
||||
)
|
||||
|
||||
|
||||
func end(conversation_id: StringName, expected_revision: int = -1) -> bool:
|
||||
var record: Dictionary = _records_by_id.get(conversation_id, {})
|
||||
if record.is_empty() or bool(record["ended"]):
|
||||
return false
|
||||
if expected_revision >= 0 and expected_revision != int(record["revision"]):
|
||||
return false
|
||||
record["ended"] = true
|
||||
record["invalid_reason"] = REASON_CONVERSATION_ENDED
|
||||
record["turn"] = null
|
||||
_records_by_id[conversation_id] = record
|
||||
return true
|
||||
|
||||
|
||||
func invalidate(conversation_id: StringName, reason_code: StringName = REASON_INVALIDATED) -> bool:
|
||||
if not ConversationSemantic.is_valid_id(reason_code):
|
||||
return false
|
||||
var record: Dictionary = _records_by_id.get(conversation_id, {})
|
||||
if record.is_empty() or bool(record["ended"]):
|
||||
return false
|
||||
record["ended"] = true
|
||||
record["invalid_reason"] = reason_code
|
||||
record["turn"] = null
|
||||
_records_by_id[conversation_id] = record
|
||||
conversation_invalidated.emit(conversation_id, reason_code)
|
||||
return true
|
||||
|
||||
|
||||
func invalidate_for_entity(
|
||||
entity: WorldEntityRef, reason_code: StringName = REASON_INVALIDATED
|
||||
) -> Array[StringName]:
|
||||
var invalidated_ids: Array[StringName] = []
|
||||
if entity == null or not entity.is_valid() or not ConversationSemantic.is_valid_id(reason_code):
|
||||
return invalidated_ids
|
||||
var conversation_ids: Array[StringName] = []
|
||||
for conversation_id in _records_by_id:
|
||||
conversation_ids.append(conversation_id)
|
||||
conversation_ids.sort_custom(
|
||||
func(left: StringName, right: StringName) -> bool: return String(left) < String(right)
|
||||
)
|
||||
for conversation_id in conversation_ids:
|
||||
var record: Dictionary = _records_by_id[conversation_id]
|
||||
if bool(record["ended"]):
|
||||
continue
|
||||
var speaker := record["speaker"] as WorldEntityRef
|
||||
var listener := record["listener"] as WorldEntityRef
|
||||
if speaker.equals(entity) or listener.equals(entity):
|
||||
if invalidate(conversation_id, reason_code):
|
||||
invalidated_ids.append(conversation_id)
|
||||
return invalidated_ids
|
||||
|
||||
|
||||
func has_active_conversation(conversation_id: StringName) -> bool:
|
||||
var record: Dictionary = _records_by_id.get(conversation_id, {})
|
||||
return not record.is_empty() and not bool(record["ended"])
|
||||
|
||||
|
||||
func _plan_turn(
|
||||
conversation_id: StringName,
|
||||
revision: int,
|
||||
speaker: WorldEntityRef,
|
||||
listener: WorldEntityRef,
|
||||
intent_id: StringName,
|
||||
context: Dictionary,
|
||||
reason_id: StringName
|
||||
) -> ConversationTurn:
|
||||
if not _catalog.has_intent(intent_id):
|
||||
return null
|
||||
var topics := _topics_for_intent(intent_id, context)
|
||||
var act := ConversationAct.new(
|
||||
&"act_%d" % revision,
|
||||
intent_id,
|
||||
speaker,
|
||||
listener,
|
||||
topics,
|
||||
[reason_id, &"intent_%s" % intent_id]
|
||||
)
|
||||
var option_intent_ids := _option_intents_for(intent_id, context)
|
||||
var options: Array[ConversationOption] = []
|
||||
for option_index in range(option_intent_ids.size()):
|
||||
var option_intent_id: StringName = option_intent_ids[option_index]
|
||||
var blocked: bool = option_intent_id in (context["blocked_intent_ids"] as Array)
|
||||
var reason_code := StringName(
|
||||
(context["block_reason_by_intent"] as Dictionary).get(option_intent_id, "blocked")
|
||||
)
|
||||
var trace: Array[StringName] = [&"candidate_from_%s" % intent_id]
|
||||
trace.append(&"blocked_by_context" if blocked else &"available_by_context")
|
||||
options.append(
|
||||
ConversationOption.new(
|
||||
&"option_%d_%d_%s" % [revision, option_index, option_intent_id],
|
||||
option_intent_id,
|
||||
listener,
|
||||
speaker,
|
||||
_topics_for_intent(option_intent_id, context),
|
||||
not blocked,
|
||||
reason_code if blocked else &"",
|
||||
trace
|
||||
)
|
||||
)
|
||||
var terminal := intent_id == ConversationIntentIds.GOODBYE
|
||||
if terminal:
|
||||
options.clear()
|
||||
var reason_trace: Array[StringName] = context["reason_trace"].duplicate()
|
||||
reason_trace.append(reason_id)
|
||||
return ConversationTurn.new(
|
||||
conversation_id, revision, speaker, listener, act, options, topics, reason_trace, terminal
|
||||
)
|
||||
|
||||
|
||||
func _option_intents_for(intent_id: StringName, context: Dictionary) -> Array[StringName]:
|
||||
var overrides: Dictionary = context["option_intent_ids"]
|
||||
if overrides.has(intent_id):
|
||||
return (overrides[intent_id] as Array).duplicate()
|
||||
return _catalog.get_response_intent_ids(intent_id)
|
||||
|
||||
|
||||
func _topics_for_intent(intent_id: StringName, context: Dictionary) -> Array[StringName]:
|
||||
var topics_by_intent: Dictionary = context["topics_by_intent"]
|
||||
if topics_by_intent.has(intent_id):
|
||||
return (topics_by_intent[intent_id] as Array).duplicate()
|
||||
return (context["topic_ids"] as Array).duplicate()
|
||||
|
||||
|
||||
func _normalize_context(context: Dictionary) -> Variant:
|
||||
for key in context:
|
||||
if (key is not String and key is not StringName) or String(key) not in CONTEXT_KEYS:
|
||||
return null
|
||||
var raw_initial_intent: Variant = context.get("initial_intent_id", ConversationIntentIds.GREET)
|
||||
if not ConversationSemantic.is_valid_id(raw_initial_intent):
|
||||
return null
|
||||
var initial_intent := StringName(raw_initial_intent)
|
||||
if _catalog == null or not _catalog.has_intent(initial_intent):
|
||||
return null
|
||||
var raw_conversation_key: Variant = context.get("conversation_key", "default")
|
||||
if not ConversationSemantic.is_valid_id(raw_conversation_key):
|
||||
return null
|
||||
var conversation_key := StringName(raw_conversation_key)
|
||||
var topic_ids: Variant = ConversationSemantic.normalize_id_array(context.get("topic_ids", []))
|
||||
var blocked: Variant = ConversationSemantic.normalize_id_array(
|
||||
context.get("blocked_intent_ids", [])
|
||||
)
|
||||
var reason_trace: Variant = ConversationSemantic.normalize_trace(
|
||||
context.get("reason_trace", [])
|
||||
)
|
||||
if topic_ids == null or blocked == null or reason_trace == null:
|
||||
return null
|
||||
for blocked_id in blocked:
|
||||
if not _catalog.has_intent(blocked_id):
|
||||
return null
|
||||
var normalized_options: Variant = _normalize_intent_map(
|
||||
context.get("option_intent_ids", {}), true
|
||||
)
|
||||
var normalized_topics: Variant = _normalize_intent_map(
|
||||
context.get("topics_by_intent", {}), false
|
||||
)
|
||||
var normalized_block_reasons: Variant = _normalize_reason_map(
|
||||
context.get("block_reason_by_intent", {})
|
||||
)
|
||||
if normalized_options == null or normalized_topics == null or normalized_block_reasons == null:
|
||||
return null
|
||||
return {
|
||||
"conversation_key": conversation_key,
|
||||
"initial_intent_id": initial_intent,
|
||||
"topic_ids": topic_ids,
|
||||
"option_intent_ids": normalized_options,
|
||||
"topics_by_intent": normalized_topics,
|
||||
"blocked_intent_ids": blocked,
|
||||
"block_reason_by_intent": normalized_block_reasons,
|
||||
"reason_trace": reason_trace,
|
||||
}
|
||||
|
||||
|
||||
func _normalize_intent_map(value: Variant, values_are_intents: bool) -> Variant:
|
||||
if value is not Dictionary:
|
||||
return null
|
||||
var normalized: Dictionary = {}
|
||||
for raw_key in value:
|
||||
if not ConversationSemantic.is_valid_id(raw_key):
|
||||
return null
|
||||
var intent_id := StringName(raw_key)
|
||||
if not _catalog.has_intent(intent_id):
|
||||
return null
|
||||
var ids: Variant = ConversationSemantic.normalize_id_array(value[raw_key])
|
||||
if ids == null:
|
||||
return null
|
||||
if values_are_intents:
|
||||
for semantic_id in ids:
|
||||
if not _catalog.has_intent(semantic_id):
|
||||
return null
|
||||
normalized[intent_id] = ids
|
||||
return normalized
|
||||
|
||||
|
||||
func _normalize_reason_map(value: Variant) -> Variant:
|
||||
if value is not Dictionary:
|
||||
return null
|
||||
var normalized: Dictionary = {}
|
||||
for raw_key in value:
|
||||
if not ConversationSemantic.is_valid_id(raw_key):
|
||||
return null
|
||||
var intent_id := StringName(raw_key)
|
||||
if not ConversationSemantic.is_valid_id(value[raw_key]):
|
||||
return null
|
||||
var reason_id := StringName(value[raw_key])
|
||||
if not _catalog.has_intent(intent_id):
|
||||
return null
|
||||
normalized[intent_id] = reason_id
|
||||
return normalized
|
||||
|
||||
|
||||
func _make_conversation_id(
|
||||
speaker: WorldEntityRef, listener: WorldEntityRef, context: Dictionary
|
||||
) -> StringName:
|
||||
var sequence := _next_sequence
|
||||
_next_sequence += 1
|
||||
var parts := [
|
||||
speaker.index_key(),
|
||||
listener.index_key(),
|
||||
String(context["conversation_key"]),
|
||||
str(sequence),
|
||||
]
|
||||
var digest := ("|".join(parts)).sha256_text().substr(0, 16)
|
||||
return StringName("conversation_%08d_%s" % [sequence, digest])
|
||||
|
||||
|
||||
func _participants_are_valid(speaker: WorldEntityRef, listener: WorldEntityRef) -> bool:
|
||||
return (
|
||||
speaker != null
|
||||
and speaker.is_valid()
|
||||
and listener != null
|
||||
and listener.is_valid()
|
||||
and not speaker.equals(listener)
|
||||
)
|
||||
|
||||
|
||||
func _rejected(
|
||||
reason_code: StringName,
|
||||
expected_revision: int,
|
||||
current_revision: int,
|
||||
option_id: StringName,
|
||||
turn: ConversationTurn,
|
||||
reason_trace: Array[StringName]
|
||||
) -> ConversationSelectionResult:
|
||||
return ConversationSelectionResult.new(
|
||||
false, reason_code, expected_revision, current_revision, option_id, turn, reason_trace
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djsasvi0p7ypb
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name ConversationTemplateCatalog
|
||||
extends Resource
|
||||
|
||||
@export var line_templates: Dictionary = {}
|
||||
@export var option_templates: Dictionary = {}
|
||||
|
||||
|
||||
func validate(intent_ids: Array[StringName] = ConversationIntentIds.ALL) -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
_validate_templates(line_templates, "line", intent_ids, errors)
|
||||
_validate_templates(option_templates, "option", intent_ids, errors)
|
||||
errors.sort()
|
||||
return errors
|
||||
|
||||
|
||||
func get_line_templates(intent_id: StringName) -> Array[String]:
|
||||
return _get_templates(line_templates, intent_id)
|
||||
|
||||
|
||||
func get_option_templates(intent_id: StringName) -> Array[String]:
|
||||
return _get_templates(option_templates, intent_id)
|
||||
|
||||
|
||||
func _get_templates(source: Dictionary, intent_id: StringName) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var values: Variant = source.get(String(intent_id), [])
|
||||
if values is Array or values is PackedStringArray:
|
||||
for value in values:
|
||||
if value is String:
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
func _validate_templates(
|
||||
source: Dictionary, kind: String, intent_ids: Array[StringName], errors: Array[String]
|
||||
) -> void:
|
||||
for intent_id in intent_ids:
|
||||
var templates := _get_templates(source, intent_id)
|
||||
if templates.is_empty():
|
||||
errors.append("Missing %s templates for '%s'" % [kind, intent_id])
|
||||
continue
|
||||
for template in templates:
|
||||
if template.strip_edges().is_empty():
|
||||
errors.append("Empty %s template for '%s'" % [kind, intent_id])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bp27f0xo61exb
|
||||
@@ -0,0 +1,122 @@
|
||||
class_name ConversationTurn
|
||||
extends RefCounted
|
||||
|
||||
var _conversation_id: StringName
|
||||
var _revision := 0
|
||||
var _speaker: WorldEntityRef
|
||||
var _listener: WorldEntityRef
|
||||
var _act: ConversationAct
|
||||
var _options: Array[ConversationOption] = []
|
||||
var _causal_topic_ids: Array[StringName] = []
|
||||
var _reason_trace: Array[StringName] = []
|
||||
var _terminal := false
|
||||
|
||||
|
||||
func _init(
|
||||
conversation_id: StringName = &"",
|
||||
revision: int = 0,
|
||||
speaker: WorldEntityRef = null,
|
||||
listener: WorldEntityRef = null,
|
||||
act: ConversationAct = null,
|
||||
options: Array[ConversationOption] = [],
|
||||
causal_topic_ids: Array[StringName] = [],
|
||||
reason_trace: Array[StringName] = [],
|
||||
terminal: bool = false
|
||||
) -> void:
|
||||
_conversation_id = conversation_id
|
||||
_revision = revision
|
||||
_speaker = ConversationSemantic.duplicate_entity(speaker)
|
||||
_listener = ConversationSemantic.duplicate_entity(listener)
|
||||
_act = act.copy() if act != null else null
|
||||
for option in options:
|
||||
_options.append(option.copy() if option != null else null)
|
||||
_causal_topic_ids = causal_topic_ids.duplicate()
|
||||
_reason_trace = reason_trace.duplicate()
|
||||
_terminal = terminal
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if (
|
||||
not ConversationSemantic.is_valid_id(_conversation_id)
|
||||
or _revision <= 0
|
||||
or _speaker == null
|
||||
or not _speaker.is_valid()
|
||||
or _listener == null
|
||||
or not _listener.is_valid()
|
||||
or _speaker.equals(_listener)
|
||||
or _act == null
|
||||
or not _act.is_valid()
|
||||
or not _act.get_speaker().equals(_speaker)
|
||||
or not _act.get_listener().equals(_listener)
|
||||
or ConversationSemantic.normalize_id_array(_causal_topic_ids) == null
|
||||
or ConversationSemantic.normalize_trace(_reason_trace) == null
|
||||
):
|
||||
return false
|
||||
var seen: Dictionary = {}
|
||||
for option in _options:
|
||||
if option == null or not option.is_valid() or seen.has(option.get_option_id()):
|
||||
return false
|
||||
if not option.get_speaker().equals(_listener) or not option.get_listener().equals(_speaker):
|
||||
return false
|
||||
seen[option.get_option_id()] = true
|
||||
return not _terminal or _options.is_empty()
|
||||
|
||||
|
||||
func get_conversation_id() -> StringName:
|
||||
return _conversation_id
|
||||
|
||||
|
||||
func get_revision() -> int:
|
||||
return _revision
|
||||
|
||||
|
||||
func get_speaker() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_speaker)
|
||||
|
||||
|
||||
func get_listener() -> WorldEntityRef:
|
||||
return ConversationSemantic.duplicate_entity(_listener)
|
||||
|
||||
|
||||
func get_act() -> ConversationAct:
|
||||
return _act.copy() if _act != null else null
|
||||
|
||||
|
||||
func get_options() -> Array[ConversationOption]:
|
||||
var result: Array[ConversationOption] = []
|
||||
for option in _options:
|
||||
result.append(option.copy())
|
||||
return result
|
||||
|
||||
|
||||
func get_option(option_id: StringName) -> ConversationOption:
|
||||
for option in _options:
|
||||
if option.get_option_id() == option_id:
|
||||
return option.copy()
|
||||
return null
|
||||
|
||||
|
||||
func get_causal_topic_ids() -> Array[StringName]:
|
||||
return _causal_topic_ids.duplicate()
|
||||
|
||||
|
||||
func get_reason_trace() -> Array[StringName]:
|
||||
return _reason_trace.duplicate()
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return _terminal
|
||||
|
||||
|
||||
func copy() -> ConversationTurn:
|
||||
return ConversationTurn.new(
|
||||
_conversation_id,
|
||||
_revision,
|
||||
_speaker,
|
||||
_listener,
|
||||
_act,
|
||||
_options,
|
||||
_causal_topic_ids,
|
||||
_reason_trace,
|
||||
_terminal
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdrdf8j5qrey4
|
||||
@@ -0,0 +1,87 @@
|
||||
[gd_resource type="Resource" script_class="ConversationIntentCatalog" load_steps=18 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/dialogue/ConversationIntentCatalog.gd" id="1_catalog"]
|
||||
[ext_resource type="Script" path="res://simulation/dialogue/ConversationIntentDefinition.gd" id="2_intent"]
|
||||
|
||||
[sub_resource type="Resource" id="Intent_greet"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"greet"
|
||||
response_intent_ids = Array[StringName]([&"ask_work", &"ask_wellbeing", &"ask_what_happened", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_ask_work"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"ask_work"
|
||||
response_intent_ids = Array[StringName]([&"describe_need", &"report_progress", &"share_fact", &"ask_who_else", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_ask_wellbeing"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"ask_wellbeing"
|
||||
response_intent_ids = Array[StringName]([&"describe_need", &"share_fact", &"offer_help", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_ask_what_happened"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"ask_what_happened"
|
||||
response_intent_ids = Array[StringName]([&"share_fact", &"ask_who_else", &"offer_help", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_share_fact"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"share_fact"
|
||||
response_intent_ids = Array[StringName]([&"ask_what_happened", &"ask_who_else", &"acknowledge_supersession", &"thank", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_describe_need"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"describe_need"
|
||||
response_intent_ids = Array[StringName]([&"offer_help", &"decline", &"ask_who_else", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_ask_who_else"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"ask_who_else"
|
||||
response_intent_ids = Array[StringName]([&"share_fact", &"offer_help", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_offer_help"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"offer_help"
|
||||
response_intent_ids = Array[StringName]([&"accept", &"decline", &"renegotiate"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_accept"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"accept"
|
||||
response_intent_ids = Array[StringName]([&"thank", &"report_progress", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_decline"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"decline"
|
||||
response_intent_ids = Array[StringName]([&"reproach", &"renegotiate", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_report_progress"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"report_progress"
|
||||
response_intent_ids = Array[StringName]([&"thank", &"renegotiate", &"acknowledge_supersession", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_renegotiate"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"renegotiate"
|
||||
response_intent_ids = Array[StringName]([&"accept", &"decline", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_thank"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"thank"
|
||||
response_intent_ids = Array[StringName]([&"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_reproach"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"reproach"
|
||||
response_intent_ids = Array[StringName]([&"acknowledge_supersession", &"renegotiate", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_acknowledge_supersession"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"acknowledge_supersession"
|
||||
response_intent_ids = Array[StringName]([&"thank", &"goodbye"])
|
||||
|
||||
[sub_resource type="Resource" id="Intent_goodbye"]
|
||||
script = ExtResource("2_intent")
|
||||
intent_id = &"goodbye"
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_catalog")
|
||||
intents = Array[ExtResource("2_intent")]([SubResource("Intent_greet"), SubResource("Intent_ask_work"), SubResource("Intent_ask_wellbeing"), SubResource("Intent_ask_what_happened"), SubResource("Intent_share_fact"), SubResource("Intent_describe_need"), SubResource("Intent_ask_who_else"), SubResource("Intent_offer_help"), SubResource("Intent_accept"), SubResource("Intent_decline"), SubResource("Intent_report_progress"), SubResource("Intent_renegotiate"), SubResource("Intent_thank"), SubResource("Intent_reproach"), SubResource("Intent_acknowledge_supersession"), SubResource("Intent_goodbye")])
|
||||
@@ -0,0 +1,42 @@
|
||||
[gd_resource type="Resource" script_class="ConversationTemplateCatalog" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/dialogue/ConversationTemplateCatalog.gd" id="1_catalog"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_catalog")
|
||||
line_templates = {
|
||||
"accept": ["That works for me.", "Yes, let us do that."],
|
||||
"acknowledge_supersession": ["Things have changed since then; I understand.", "That news has overtaken our earlier plan."],
|
||||
"ask_wellbeing": ["How have you been, {listener}?", "Are you keeping well, {listener}?"],
|
||||
"ask_what_happened": ["What happened with {topic}?", "Tell me what happened around {topic}."],
|
||||
"ask_who_else": ["Who else knows about {topic}?", "Who else should hear about {topic}?"],
|
||||
"ask_work": ["What work is keeping you busy?", "What are you working on today?"],
|
||||
"decline": ["I cannot agree to that.", "No, I cannot take that on."],
|
||||
"describe_need": ["We need help with {topic}.", "The pressing need is {topic}."],
|
||||
"goodbye": ["Until next time.", "Farewell for now."],
|
||||
"greet": ["Good to see you, {listener}.", "Peace to you, {listener}."],
|
||||
"offer_help": ["I can help with {topic}.", "Let me lend a hand with {topic}."],
|
||||
"renegotiate": ["We should revise the plan for {topic}.", "Can we find another way to handle {topic}?"],
|
||||
"report_progress": ["There has been progress on {topic}.", "The work on {topic} is moving forward."],
|
||||
"reproach": ["You left {topic} unresolved.", "I expected better over {topic}."],
|
||||
"share_fact": ["I learned something about {topic}.", "There is news concerning {topic}."],
|
||||
"thank": ["Thank you for your help.", "You have my thanks."]
|
||||
}
|
||||
option_templates = {
|
||||
"accept": ["Accept.", "Agree to the plan."],
|
||||
"acknowledge_supersession": ["Acknowledge that events changed."],
|
||||
"ask_wellbeing": ["Ask how they are."],
|
||||
"ask_what_happened": ["Ask what happened."],
|
||||
"ask_who_else": ["Ask who else knows."],
|
||||
"ask_work": ["Ask about their work."],
|
||||
"decline": ["Decline.", "Say you cannot help."],
|
||||
"describe_need": ["Describe the need."],
|
||||
"goodbye": ["Say goodbye."],
|
||||
"greet": ["Greet them."],
|
||||
"offer_help": ["Offer help with {topic}."],
|
||||
"renegotiate": ["Propose a different plan."],
|
||||
"report_progress": ["Report progress."],
|
||||
"reproach": ["Reproach them."],
|
||||
"share_fact": ["Share what you know."],
|
||||
"thank": ["Thank them."]
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
extends SceneTree
|
||||
|
||||
|
||||
class FakePresenter:
|
||||
extends ConversationPresenter
|
||||
var presented_turn: ConversationTurn
|
||||
|
||||
func present_turn(turn: ConversationTurn, _presentation_context: Dictionary = {}) -> Variant:
|
||||
presented_turn = turn.copy()
|
||||
return {
|
||||
"conversation_id": turn.get_conversation_id(),
|
||||
"revision": turn.get_revision(),
|
||||
}
|
||||
|
||||
|
||||
const CATALOG_PATH := "res://simulation/dialogue/resources/jajce_conversation_intents.tres"
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_test_catalog_and_deterministic_lifecycle()
|
||||
_test_revalidation_and_invalidation()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_catalog_and_deterministic_lifecycle() -> void:
|
||||
var catalog := load(CATALOG_PATH) as ConversationIntentCatalog
|
||||
_check(
|
||||
catalog != null and catalog.rebuild().is_empty(), "The Jajce intent catalog should validate"
|
||||
)
|
||||
if catalog == null:
|
||||
return
|
||||
_check(
|
||||
catalog.get_intent_ids() == _sorted_ids(ConversationIntentIds.ALL),
|
||||
"The catalog should expose every required initial intent"
|
||||
)
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
var context := _context()
|
||||
var first_service := ConversationService.new(catalog)
|
||||
var second_service := ConversationService.new(catalog)
|
||||
var first_id := first_service.begin(speaker, listener, context)
|
||||
var second_id := second_service.begin(speaker, listener, context)
|
||||
_check(not first_id.is_empty(), "A valid semantic conversation should begin")
|
||||
_check(first_id == second_id, "Equivalent begin order and context should produce a stable ID")
|
||||
var first_turn := first_service.get_turn(first_id)
|
||||
var second_turn := second_service.get_turn(second_id)
|
||||
_check(first_turn != null and first_turn.is_valid(), "The first planned turn should validate")
|
||||
if first_turn == null or second_turn == null:
|
||||
return
|
||||
_check(
|
||||
(
|
||||
first_turn.get_revision() == 1
|
||||
and first_turn.get_act().get_intent_id() == ConversationIntentIds.GREET
|
||||
),
|
||||
"The initial turn should expose a revisioned semantic greeting"
|
||||
)
|
||||
_check(
|
||||
first_turn.get_act().get_causal_topic_ids() == [&"pantry_shortage"],
|
||||
"The act should retain causal topic IDs without authoritative prose"
|
||||
)
|
||||
_check(
|
||||
_turn_signature(first_turn) == _turn_signature(second_turn),
|
||||
"Equivalent semantic inputs should plan the same turn and option order"
|
||||
)
|
||||
var fake := FakePresenter.new()
|
||||
var rendered: Dictionary = fake.present_turn(first_turn)
|
||||
_check(
|
||||
rendered["revision"] == 1 and fake.presented_turn.get_act().get_intent_id() == &"greet",
|
||||
"A presenter should receive an already-planned immutable semantic turn"
|
||||
)
|
||||
context["topic_ids"].append(&"caller_mutation")
|
||||
_check(
|
||||
first_service.get_turn(first_id).get_causal_topic_ids() == [&"pantry_shortage"],
|
||||
"Conversation context should be isolated from caller mutation"
|
||||
)
|
||||
|
||||
|
||||
func _test_revalidation_and_invalidation() -> void:
|
||||
var service := ConversationService.new()
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
_check(
|
||||
service.begin(speaker, listener, {"prose": "not semantic"}).is_empty(),
|
||||
"Unknown or prose-bearing authority keys should be rejected"
|
||||
)
|
||||
var conversation_id := service.begin(speaker, listener, _context())
|
||||
var turn := service.get_turn(conversation_id)
|
||||
if turn == null:
|
||||
_check(false, "A conversation should exist for revision tests")
|
||||
return
|
||||
var ask_work := _find_option_for_intent(turn, ConversationIntentIds.ASK_WORK)
|
||||
var offer_help := _find_option_for_intent(turn, ConversationIntentIds.OFFER_HELP)
|
||||
_check(ask_work != null and offer_help != null, "Configured semantic options should be present")
|
||||
if ask_work == null or offer_help == null:
|
||||
return
|
||||
_check(
|
||||
not offer_help.is_enabled() and offer_help.get_reason_code() == &"speaker_overcommitted",
|
||||
"Blocked choices should retain a semantic reason code and trace"
|
||||
)
|
||||
var disabled := service.select_option(
|
||||
conversation_id, offer_help.get_option_id(), turn.get_revision()
|
||||
)
|
||||
_check(
|
||||
(
|
||||
not disabled.was_accepted()
|
||||
and disabled.get_reason_code() == ConversationService.REASON_OPTION_DISABLED
|
||||
and service.get_turn(conversation_id).get_revision() == 1
|
||||
),
|
||||
"A disabled choice should be rejected without advancing revision"
|
||||
)
|
||||
var stale := service.select_option(conversation_id, ask_work.get_option_id(), 0)
|
||||
_check(
|
||||
(
|
||||
not stale.was_accepted()
|
||||
and stale.get_reason_code() == ConversationService.REASON_STALE_REVISION
|
||||
and stale.get_current_revision() == 1
|
||||
and stale.get_turn().get_revision() == 1
|
||||
),
|
||||
"A stale selection should return the current revision for revalidation"
|
||||
)
|
||||
var selected := service.select_option(
|
||||
conversation_id, ask_work.get_option_id(), turn.get_revision()
|
||||
)
|
||||
_check(
|
||||
(
|
||||
selected.was_accepted()
|
||||
and selected.get_current_revision() == 2
|
||||
and selected.get_turn().get_act().get_intent_id() == ConversationIntentIds.ASK_WORK
|
||||
),
|
||||
"A current enabled option should advance exactly one semantic revision"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
selected.get_turn().get_speaker().equals(listener)
|
||||
and selected.get_turn().get_listener().equals(speaker)
|
||||
),
|
||||
"Selecting a listener option should swap the next turn's speaking direction"
|
||||
)
|
||||
_check(
|
||||
not service.select_option(conversation_id, ask_work.get_option_id(), 1).was_accepted(),
|
||||
"A replayed option should fail stale-revision validation"
|
||||
)
|
||||
var invalidations: Array[StringName] = []
|
||||
service.conversation_invalidated.connect(
|
||||
func(id: StringName, _reason: StringName) -> void: invalidations.append(id)
|
||||
)
|
||||
_check(
|
||||
service.invalidate_for_entity(listener, &"listener_unloaded") == [conversation_id],
|
||||
"Entity invalidation should deterministically close its active conversations"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
invalidations == [conversation_id]
|
||||
and service.get_turn(conversation_id) == null
|
||||
and not service.has_active_conversation(conversation_id)
|
||||
),
|
||||
"Invalidation should emit a hook and make the turn inaccessible"
|
||||
)
|
||||
|
||||
|
||||
func _context() -> Dictionary:
|
||||
return {
|
||||
"conversation_key": &"pantry_encounter",
|
||||
"initial_intent_id": ConversationIntentIds.GREET,
|
||||
"topic_ids": [&"pantry_shortage"],
|
||||
"option_intent_ids":
|
||||
{
|
||||
ConversationIntentIds.GREET:
|
||||
[ConversationIntentIds.ASK_WORK, ConversationIntentIds.OFFER_HELP]
|
||||
},
|
||||
"topics_by_intent":
|
||||
{
|
||||
ConversationIntentIds.OFFER_HELP: [&"pantry_shortage"],
|
||||
ConversationIntentIds.ASK_WORK: [&"pantry_shortage"],
|
||||
},
|
||||
"blocked_intent_ids": [ConversationIntentIds.OFFER_HELP],
|
||||
"block_reason_by_intent": {ConversationIntentIds.OFFER_HELP: &"speaker_overcommitted"},
|
||||
"reason_trace": [&"participants_nearby", &"shared_topic_available"],
|
||||
}
|
||||
|
||||
|
||||
func _find_option_for_intent(turn: ConversationTurn, intent_id: StringName) -> ConversationOption:
|
||||
for option in turn.get_options():
|
||||
if option.get_intent_id() == intent_id:
|
||||
return option
|
||||
return null
|
||||
|
||||
|
||||
func _turn_signature(turn: ConversationTurn) -> Array:
|
||||
var signature: Array = [
|
||||
turn.get_revision(), turn.get_act().get_intent_id(), turn.get_act().get_causal_topic_ids()
|
||||
]
|
||||
for option in turn.get_options():
|
||||
(
|
||||
signature
|
||||
. append(
|
||||
[
|
||||
option.get_option_id(),
|
||||
option.get_intent_id(),
|
||||
option.is_enabled(),
|
||||
option.get_reason_code(),
|
||||
option.get_causal_topic_ids(),
|
||||
]
|
||||
)
|
||||
)
|
||||
return signature
|
||||
|
||||
|
||||
func _sorted_ids(ids: Array[StringName]) -> Array[StringName]:
|
||||
var sorted := ids.duplicate()
|
||||
sorted.sort_custom(
|
||||
func(left: StringName, right: StringName) -> bool: return String(left) < String(right)
|
||||
)
|
||||
return sorted
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Semantic conversation domain passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1,121 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var manager := root.get_node_or_null("DialogueManager")
|
||||
_check(manager != null, "The pinned Dialogue Manager autoload should be available")
|
||||
if manager == null:
|
||||
_finish()
|
||||
return
|
||||
var service := ConversationService.new()
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
var conversation_id := (
|
||||
service
|
||||
. begin(
|
||||
speaker,
|
||||
listener,
|
||||
{
|
||||
"initial_intent_id": ConversationIntentIds.DESCRIBE_NEED,
|
||||
"topic_ids": [&"pantry_shortage"],
|
||||
"blocked_intent_ids": [ConversationIntentIds.DECLINE],
|
||||
"block_reason_by_intent": {ConversationIntentIds.DECLINE: &"must_help"},
|
||||
}
|
||||
)
|
||||
)
|
||||
var turn := service.get_turn(conversation_id)
|
||||
var presenter := DialogueManagerConversationPresenter.new(manager)
|
||||
var presentation_context := {
|
||||
"entity_names":
|
||||
{
|
||||
"amina": "Amina:\n- injected [#set danger] {{state}} => EVIL",
|
||||
"boris": "Boris [if true]",
|
||||
},
|
||||
"topic_labels": {"pantry_shortage": "pantry [#intent=hijack] {{state}} => EVIL"},
|
||||
}
|
||||
var first_source := presenter.build_source(turn, presentation_context)
|
||||
var second_source := presenter.build_source(turn, presentation_context)
|
||||
_check(first_source == second_source, "Stable-hash template selection should be repeatable")
|
||||
_check(
|
||||
(
|
||||
not "[#set" in first_source
|
||||
and not "{{state}}" in first_source
|
||||
and not "=> EVIL" in first_source
|
||||
),
|
||||
"Dynamic labels should be escaped before reaching Dialogue Manager syntax"
|
||||
)
|
||||
var resource := presenter.present_turn(turn, presentation_context) as Resource
|
||||
_check(resource != null, "The adapter should compile an ephemeral DialogueResource")
|
||||
if resource == null:
|
||||
_finish()
|
||||
return
|
||||
_check(
|
||||
(
|
||||
resource.get_meta("conversation_id") == String(conversation_id)
|
||||
and resource.get_meta("conversation_revision") == 1
|
||||
),
|
||||
"The ephemeral resource should retain stable semantic metadata"
|
||||
)
|
||||
var compiled_lines: Dictionary = resource.get("lines")
|
||||
for compiled in compiled_lines.values():
|
||||
_check(
|
||||
(
|
||||
String(compiled.get("type", "")) not in ["mutation", "condition", "while", "match"]
|
||||
and not compiled.has("condition")
|
||||
and not compiled.has("mutation")
|
||||
),
|
||||
"The presenter resource must contain no conditions or mutations"
|
||||
)
|
||||
var line: DialogueLine = await manager.get_next_dialogue_line(resource, "start")
|
||||
_check(line != null, "The compiled semantic turn should resolve through the pinned add-on")
|
||||
if line != null:
|
||||
_check(
|
||||
(
|
||||
line.get_tag_value("conversation_id") == String(conversation_id)
|
||||
and line.get_tag_value("intent_id") == "describe_need"
|
||||
and line.get_tag_value("topic_id") == "pantry_shortage"
|
||||
),
|
||||
"Controlled semantic IDs should survive as Dialogue Manager tags"
|
||||
)
|
||||
var enabled_count := 0
|
||||
for option in turn.get_options():
|
||||
if option.is_enabled():
|
||||
enabled_count += 1
|
||||
_check(
|
||||
line.responses.size() == enabled_count,
|
||||
"Disabled semantic choices should not become selectable presenter responses"
|
||||
)
|
||||
for response in line.responses:
|
||||
_check(
|
||||
(
|
||||
response.get_tag_value("option_id").begins_with("option_")
|
||||
and response.get_tag_value("enabled") == "true"
|
||||
),
|
||||
"Every response should carry its controlled semantic option identity"
|
||||
)
|
||||
line.extra_game_states.clear()
|
||||
line.responses.clear()
|
||||
resource.set("lines", {})
|
||||
resource.set_script(null)
|
||||
_finish()
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Dialogue Manager semantic presenter passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1,203 @@
|
||||
class_name DialogueManagerConversationPresenter
|
||||
extends ConversationPresenter
|
||||
|
||||
const DEFAULT_TEMPLATE_CATALOG_PATH := "res://simulation/dialogue/resources/jajce_conversation_templates.tres"
|
||||
|
||||
var last_error := ""
|
||||
|
||||
var _dialogue_manager: Object
|
||||
var _templates: ConversationTemplateCatalog
|
||||
|
||||
|
||||
func _init(
|
||||
dialogue_manager: Object = null, template_catalog: ConversationTemplateCatalog = null
|
||||
) -> void:
|
||||
_dialogue_manager = dialogue_manager
|
||||
_templates = template_catalog
|
||||
if _templates == null:
|
||||
_templates = load(DEFAULT_TEMPLATE_CATALOG_PATH) as ConversationTemplateCatalog
|
||||
|
||||
|
||||
func present_turn(turn: ConversationTurn, presentation_context: Dictionary = {}) -> Variant:
|
||||
last_error = ""
|
||||
if turn == null or not turn.is_valid():
|
||||
return _fail("Conversation turn is invalid")
|
||||
if not _presentation_context_is_valid(presentation_context):
|
||||
return _fail("Presentation context is invalid")
|
||||
var manager := _resolve_dialogue_manager()
|
||||
if manager == null or not manager.has_method("create_resource_from_text"):
|
||||
return _fail("Dialogue Manager v3 runtime is unavailable")
|
||||
if _templates == null or not _templates.validate().is_empty():
|
||||
return _fail("Conversation template catalog is invalid")
|
||||
|
||||
var source := build_source(turn, presentation_context)
|
||||
if source.is_empty():
|
||||
return null
|
||||
var resource: Variant = manager.call("create_resource_from_text", source)
|
||||
if resource is Resource:
|
||||
resource.set_meta("conversation_id", String(turn.get_conversation_id()))
|
||||
resource.set_meta("conversation_revision", turn.get_revision())
|
||||
resource.set_meta("semantic_act_id", String(turn.get_act().get_act_id()))
|
||||
resource.set_meta("semantic_intent_id", String(turn.get_act().get_intent_id()))
|
||||
return resource
|
||||
|
||||
|
||||
func build_source(turn: ConversationTurn, presentation_context: Dictionary = {}) -> String:
|
||||
last_error = ""
|
||||
if turn == null or not turn.is_valid():
|
||||
return _fail_string("Conversation turn is invalid")
|
||||
if not _presentation_context_is_valid(presentation_context):
|
||||
return _fail_string("Presentation context is invalid")
|
||||
if _templates == null or not _templates.validate().is_empty():
|
||||
return _fail_string("Conversation template catalog is invalid")
|
||||
|
||||
var act := turn.get_act()
|
||||
var speaker_label := _entity_name(act.get_speaker(), presentation_context)
|
||||
var listener_label := _entity_name(act.get_listener(), presentation_context)
|
||||
var topic_label := _topic_name(act.get_causal_topic_ids(), presentation_context)
|
||||
var speaker_name := sanitize_dialogue_text(speaker_label)
|
||||
var line_text := _render_template(
|
||||
_select_template(
|
||||
_templates.get_line_templates(act.get_intent_id()),
|
||||
_stable_key(turn, act.get_act_id(), act.get_intent_id(), &"line")
|
||||
),
|
||||
speaker_label,
|
||||
listener_label,
|
||||
topic_label
|
||||
)
|
||||
var line_tags := PackedStringArray(
|
||||
[
|
||||
"conversation_id=%s" % turn.get_conversation_id(),
|
||||
"revision=%d" % turn.get_revision(),
|
||||
"act_id=%s" % act.get_act_id(),
|
||||
"intent_id=%s" % act.get_intent_id(),
|
||||
]
|
||||
)
|
||||
for topic_id in act.get_causal_topic_ids():
|
||||
line_tags.append("topic_id=%s" % topic_id)
|
||||
var lines := PackedStringArray(
|
||||
[
|
||||
"~ start",
|
||||
"%s: %s [#%s]" % [speaker_name, line_text, ",".join(line_tags)],
|
||||
]
|
||||
)
|
||||
for option in turn.get_options():
|
||||
if not option.is_enabled():
|
||||
continue
|
||||
var option_topic := _topic_name(option.get_causal_topic_ids(), presentation_context)
|
||||
var option_text := _render_template(
|
||||
_select_template(
|
||||
_templates.get_option_templates(option.get_intent_id()),
|
||||
_stable_key(turn, option.get_option_id(), option.get_intent_id(), &"option")
|
||||
),
|
||||
_entity_name(option.get_speaker(), presentation_context),
|
||||
_entity_name(option.get_listener(), presentation_context),
|
||||
option_topic
|
||||
)
|
||||
var option_tags := PackedStringArray(
|
||||
[
|
||||
"option_id=%s" % option.get_option_id(),
|
||||
"intent_id=%s" % option.get_intent_id(),
|
||||
"enabled=true",
|
||||
]
|
||||
)
|
||||
for topic_id in option.get_causal_topic_ids():
|
||||
option_tags.append("topic_id=%s" % topic_id)
|
||||
lines.append("- %s [#%s] => END" % [option_text, ",".join(option_tags)])
|
||||
lines.append("=> END")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
static func sanitize_dialogue_text(value: String) -> String:
|
||||
var sanitized := value.replace("\\", "\")
|
||||
sanitized = sanitized.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
||||
sanitized = sanitized.replace("[", "[").replace("]", "]")
|
||||
sanitized = sanitized.replace("{", "{").replace("}", "}")
|
||||
sanitized = sanitized.replace("=>", "=>")
|
||||
sanitized = sanitized.replace(":", "\\:")
|
||||
return " ".join(sanitized.split(" ", false)).strip_edges()
|
||||
|
||||
|
||||
func _stable_key(
|
||||
turn: ConversationTurn, semantic_id: StringName, intent_id: StringName, kind: StringName
|
||||
) -> String:
|
||||
return (
|
||||
"%s|%d|%s|%s|%s"
|
||||
% [turn.get_conversation_id(), turn.get_revision(), semantic_id, intent_id, kind]
|
||||
)
|
||||
|
||||
|
||||
func _select_template(templates: Array[String], stable_key: String) -> String:
|
||||
if templates.is_empty():
|
||||
return "..."
|
||||
var digest := stable_key.sha256_text()
|
||||
var stable_index := 0
|
||||
for index in range(16):
|
||||
stable_index = (stable_index * 33 + digest.unicode_at(index)) % templates.size()
|
||||
return templates[stable_index]
|
||||
|
||||
|
||||
func _render_template(
|
||||
template: String, speaker_name: String, listener_name: String, topic_name: String
|
||||
) -> String:
|
||||
return sanitize_dialogue_text(
|
||||
template.replace("{speaker}", speaker_name).replace("{listener}", listener_name).replace(
|
||||
"{topic}", topic_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _entity_name(entity: WorldEntityRef, context: Dictionary) -> String:
|
||||
var names: Dictionary = context.get("entity_names", {})
|
||||
var value := String(
|
||||
names.get(entity.index_key(), names.get(String(entity.get_entity_id()), ""))
|
||||
)
|
||||
if value.is_empty():
|
||||
value = String(entity.get_entity_id()).replace("_", " ").capitalize()
|
||||
return value
|
||||
|
||||
|
||||
func _topic_name(topic_ids: Array[StringName], context: Dictionary) -> String:
|
||||
if topic_ids.is_empty():
|
||||
return "the matter at hand"
|
||||
var labels: Dictionary = context.get("topic_labels", {})
|
||||
var topic_id := topic_ids[0]
|
||||
var value := String(labels.get(String(topic_id), ""))
|
||||
if value.is_empty():
|
||||
value = String(topic_id).replace("_", " ")
|
||||
return value
|
||||
|
||||
|
||||
func _presentation_context_is_valid(context: Dictionary) -> bool:
|
||||
for key in context:
|
||||
if (
|
||||
(key is not String and key is not StringName)
|
||||
or String(key) not in ["entity_names", "topic_labels"]
|
||||
or context[key] is not Dictionary
|
||||
):
|
||||
return false
|
||||
for entry_key in context[key] as Dictionary:
|
||||
if entry_key is not String and entry_key is not StringName:
|
||||
return false
|
||||
var value: Variant = context[key][entry_key]
|
||||
if value is not String:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _resolve_dialogue_manager() -> Object:
|
||||
if _dialogue_manager != null and is_instance_valid(_dialogue_manager):
|
||||
return _dialogue_manager
|
||||
if Engine.has_singleton("DialogueManager"):
|
||||
return Engine.get_singleton("DialogueManager")
|
||||
return null
|
||||
|
||||
|
||||
func _fail(message: String) -> Variant:
|
||||
last_error = message
|
||||
return null
|
||||
|
||||
|
||||
func _fail_string(message: String) -> String:
|
||||
last_error = message
|
||||
return ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://b20ui00dxel0x
|
||||
Reference in New Issue
Block a user