419 lines
13 KiB
GDScript
419 lines
13 KiB
GDScript
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
|
|
)
|