feat: plan deterministic generated conversations

This commit is contained in:
Rijad Zuzo
2026-08-12 20:32:53 +02:00
parent 4b58c534d0
commit 41b37a4962
28 changed files with 1694 additions and 0 deletions
+69
View File
@@ -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
+91
View File
@@ -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
+418
View File
@@ -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
+122
View File
@@ -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."]
}