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 )