From 41b37a49624ead730c021d46e4ffdb050dfd105e Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 20:32:53 +0200 Subject: [PATCH] feat: plan deterministic generated conversations --- simulation/dialogue/ConversationAct.gd | 69 +++ simulation/dialogue/ConversationAct.gd.uid | 1 + .../dialogue/ConversationIntentCatalog.gd | 70 +++ .../dialogue/ConversationIntentCatalog.gd.uid | 1 + .../dialogue/ConversationIntentDefinition.gd | 22 + .../ConversationIntentDefinition.gd.uid | 1 + simulation/dialogue/ConversationIntentIds.gd | 42 ++ .../dialogue/ConversationIntentIds.gd.uid | 1 + simulation/dialogue/ConversationOption.gd | 91 ++++ simulation/dialogue/ConversationOption.gd.uid | 1 + simulation/dialogue/ConversationPresenter.gd | 8 + .../dialogue/ConversationPresenter.gd.uid | 1 + .../dialogue/ConversationSelectionResult.gd | 56 +++ .../ConversationSelectionResult.gd.uid | 1 + simulation/dialogue/ConversationSemantic.gd | 56 +++ .../dialogue/ConversationSemantic.gd.uid | 1 + simulation/dialogue/ConversationService.gd | 418 ++++++++++++++++++ .../dialogue/ConversationService.gd.uid | 1 + .../dialogue/ConversationTemplateCatalog.gd | 44 ++ .../ConversationTemplateCatalog.gd.uid | 1 + simulation/dialogue/ConversationTurn.gd | 122 +++++ simulation/dialogue/ConversationTurn.gd.uid | 1 + .../resources/jajce_conversation_intents.tres | 87 ++++ .../jajce_conversation_templates.tres | 42 ++ tests/conversation_domain_test.gd | 231 ++++++++++ ...gue_manager_conversation_presenter_test.gd | 121 +++++ .../DialogueManagerConversationPresenter.gd | 203 +++++++++ ...ialogueManagerConversationPresenter.gd.uid | 1 + 28 files changed, 1694 insertions(+) create mode 100644 simulation/dialogue/ConversationAct.gd create mode 100644 simulation/dialogue/ConversationAct.gd.uid create mode 100644 simulation/dialogue/ConversationIntentCatalog.gd create mode 100644 simulation/dialogue/ConversationIntentCatalog.gd.uid create mode 100644 simulation/dialogue/ConversationIntentDefinition.gd create mode 100644 simulation/dialogue/ConversationIntentDefinition.gd.uid create mode 100644 simulation/dialogue/ConversationIntentIds.gd create mode 100644 simulation/dialogue/ConversationIntentIds.gd.uid create mode 100644 simulation/dialogue/ConversationOption.gd create mode 100644 simulation/dialogue/ConversationOption.gd.uid create mode 100644 simulation/dialogue/ConversationPresenter.gd create mode 100644 simulation/dialogue/ConversationPresenter.gd.uid create mode 100644 simulation/dialogue/ConversationSelectionResult.gd create mode 100644 simulation/dialogue/ConversationSelectionResult.gd.uid create mode 100644 simulation/dialogue/ConversationSemantic.gd create mode 100644 simulation/dialogue/ConversationSemantic.gd.uid create mode 100644 simulation/dialogue/ConversationService.gd create mode 100644 simulation/dialogue/ConversationService.gd.uid create mode 100644 simulation/dialogue/ConversationTemplateCatalog.gd create mode 100644 simulation/dialogue/ConversationTemplateCatalog.gd.uid create mode 100644 simulation/dialogue/ConversationTurn.gd create mode 100644 simulation/dialogue/ConversationTurn.gd.uid create mode 100644 simulation/dialogue/resources/jajce_conversation_intents.tres create mode 100644 simulation/dialogue/resources/jajce_conversation_templates.tres create mode 100644 tests/conversation_domain_test.gd create mode 100644 tests/dialogue_manager_conversation_presenter_test.gd create mode 100644 world/dialogue/DialogueManagerConversationPresenter.gd create mode 100644 world/dialogue/DialogueManagerConversationPresenter.gd.uid diff --git a/simulation/dialogue/ConversationAct.gd b/simulation/dialogue/ConversationAct.gd new file mode 100644 index 0000000..00976f9 --- /dev/null +++ b/simulation/dialogue/ConversationAct.gd @@ -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 + ) diff --git a/simulation/dialogue/ConversationAct.gd.uid b/simulation/dialogue/ConversationAct.gd.uid new file mode 100644 index 0000000..250a0d5 --- /dev/null +++ b/simulation/dialogue/ConversationAct.gd.uid @@ -0,0 +1 @@ +uid://dsddan1v334nb diff --git a/simulation/dialogue/ConversationIntentCatalog.gd b/simulation/dialogue/ConversationIntentCatalog.gd new file mode 100644 index 0000000..de47d27 --- /dev/null +++ b/simulation/dialogue/ConversationIntentCatalog.gd @@ -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() diff --git a/simulation/dialogue/ConversationIntentCatalog.gd.uid b/simulation/dialogue/ConversationIntentCatalog.gd.uid new file mode 100644 index 0000000..28828f7 --- /dev/null +++ b/simulation/dialogue/ConversationIntentCatalog.gd.uid @@ -0,0 +1 @@ +uid://b1mqen7pdhv0m diff --git a/simulation/dialogue/ConversationIntentDefinition.gd b/simulation/dialogue/ConversationIntentDefinition.gd new file mode 100644 index 0000000..dd1c004 --- /dev/null +++ b/simulation/dialogue/ConversationIntentDefinition.gd @@ -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 diff --git a/simulation/dialogue/ConversationIntentDefinition.gd.uid b/simulation/dialogue/ConversationIntentDefinition.gd.uid new file mode 100644 index 0000000..ad2807e --- /dev/null +++ b/simulation/dialogue/ConversationIntentDefinition.gd.uid @@ -0,0 +1 @@ +uid://40cns84o6dj5 diff --git a/simulation/dialogue/ConversationIntentIds.gd b/simulation/dialogue/ConversationIntentIds.gd new file mode 100644 index 0000000..a3cdbd2 --- /dev/null +++ b/simulation/dialogue/ConversationIntentIds.gd @@ -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 diff --git a/simulation/dialogue/ConversationIntentIds.gd.uid b/simulation/dialogue/ConversationIntentIds.gd.uid new file mode 100644 index 0000000..8116be1 --- /dev/null +++ b/simulation/dialogue/ConversationIntentIds.gd.uid @@ -0,0 +1 @@ +uid://b4f6plcdw77xe diff --git a/simulation/dialogue/ConversationOption.gd b/simulation/dialogue/ConversationOption.gd new file mode 100644 index 0000000..343da31 --- /dev/null +++ b/simulation/dialogue/ConversationOption.gd @@ -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 + ) diff --git a/simulation/dialogue/ConversationOption.gd.uid b/simulation/dialogue/ConversationOption.gd.uid new file mode 100644 index 0000000..5e79be2 --- /dev/null +++ b/simulation/dialogue/ConversationOption.gd.uid @@ -0,0 +1 @@ +uid://cx7fllak35woy diff --git a/simulation/dialogue/ConversationPresenter.gd b/simulation/dialogue/ConversationPresenter.gd new file mode 100644 index 0000000..4e8a67c --- /dev/null +++ b/simulation/dialogue/ConversationPresenter.gd @@ -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 diff --git a/simulation/dialogue/ConversationPresenter.gd.uid b/simulation/dialogue/ConversationPresenter.gd.uid new file mode 100644 index 0000000..ec2a075 --- /dev/null +++ b/simulation/dialogue/ConversationPresenter.gd.uid @@ -0,0 +1 @@ +uid://5d4pi5tu3bjk diff --git a/simulation/dialogue/ConversationSelectionResult.gd b/simulation/dialogue/ConversationSelectionResult.gd new file mode 100644 index 0000000..6e22f1e --- /dev/null +++ b/simulation/dialogue/ConversationSelectionResult.gd @@ -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() diff --git a/simulation/dialogue/ConversationSelectionResult.gd.uid b/simulation/dialogue/ConversationSelectionResult.gd.uid new file mode 100644 index 0000000..60a5c92 --- /dev/null +++ b/simulation/dialogue/ConversationSelectionResult.gd.uid @@ -0,0 +1 @@ +uid://uvl0i6lfcs3s diff --git a/simulation/dialogue/ConversationSemantic.gd b/simulation/dialogue/ConversationSemantic.gd new file mode 100644 index 0000000..c8bb635 --- /dev/null +++ b/simulation/dialogue/ConversationSemantic.gd @@ -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 ["_", "-", ".", ":"] + ) diff --git a/simulation/dialogue/ConversationSemantic.gd.uid b/simulation/dialogue/ConversationSemantic.gd.uid new file mode 100644 index 0000000..501a993 --- /dev/null +++ b/simulation/dialogue/ConversationSemantic.gd.uid @@ -0,0 +1 @@ +uid://c8ijyl3xk1cdi diff --git a/simulation/dialogue/ConversationService.gd b/simulation/dialogue/ConversationService.gd new file mode 100644 index 0000000..8a64b34 --- /dev/null +++ b/simulation/dialogue/ConversationService.gd @@ -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 + ) diff --git a/simulation/dialogue/ConversationService.gd.uid b/simulation/dialogue/ConversationService.gd.uid new file mode 100644 index 0000000..44ab340 --- /dev/null +++ b/simulation/dialogue/ConversationService.gd.uid @@ -0,0 +1 @@ +uid://djsasvi0p7ypb diff --git a/simulation/dialogue/ConversationTemplateCatalog.gd b/simulation/dialogue/ConversationTemplateCatalog.gd new file mode 100644 index 0000000..940d13c --- /dev/null +++ b/simulation/dialogue/ConversationTemplateCatalog.gd @@ -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]) diff --git a/simulation/dialogue/ConversationTemplateCatalog.gd.uid b/simulation/dialogue/ConversationTemplateCatalog.gd.uid new file mode 100644 index 0000000..0fd2d4c --- /dev/null +++ b/simulation/dialogue/ConversationTemplateCatalog.gd.uid @@ -0,0 +1 @@ +uid://bp27f0xo61exb diff --git a/simulation/dialogue/ConversationTurn.gd b/simulation/dialogue/ConversationTurn.gd new file mode 100644 index 0000000..6d3b752 --- /dev/null +++ b/simulation/dialogue/ConversationTurn.gd @@ -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 + ) diff --git a/simulation/dialogue/ConversationTurn.gd.uid b/simulation/dialogue/ConversationTurn.gd.uid new file mode 100644 index 0000000..f03ef0d --- /dev/null +++ b/simulation/dialogue/ConversationTurn.gd.uid @@ -0,0 +1 @@ +uid://cdrdf8j5qrey4 diff --git a/simulation/dialogue/resources/jajce_conversation_intents.tres b/simulation/dialogue/resources/jajce_conversation_intents.tres new file mode 100644 index 0000000..d284da0 --- /dev/null +++ b/simulation/dialogue/resources/jajce_conversation_intents.tres @@ -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")]) diff --git a/simulation/dialogue/resources/jajce_conversation_templates.tres b/simulation/dialogue/resources/jajce_conversation_templates.tres new file mode 100644 index 0000000..ab03616 --- /dev/null +++ b/simulation/dialogue/resources/jajce_conversation_templates.tres @@ -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."] +} diff --git a/tests/conversation_domain_test.gd b/tests/conversation_domain_test.gd new file mode 100644 index 0000000..548b98a --- /dev/null +++ b/tests/conversation_domain_test.gd @@ -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) diff --git a/tests/dialogue_manager_conversation_presenter_test.gd b/tests/dialogue_manager_conversation_presenter_test.gd new file mode 100644 index 0000000..e261f6e --- /dev/null +++ b/tests/dialogue_manager_conversation_presenter_test.gd @@ -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) diff --git a/world/dialogue/DialogueManagerConversationPresenter.gd b/world/dialogue/DialogueManagerConversationPresenter.gd new file mode 100644 index 0000000..e60bb11 --- /dev/null +++ b/world/dialogue/DialogueManagerConversationPresenter.gd @@ -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 "" diff --git a/world/dialogue/DialogueManagerConversationPresenter.gd.uid b/world/dialogue/DialogueManagerConversationPresenter.gd.uid new file mode 100644 index 0000000..32247c4 --- /dev/null +++ b/world/dialogue/DialogueManagerConversationPresenter.gd.uid @@ -0,0 +1 @@ +uid://b20ui00dxel0x