"""Keep generated native API text inputs in the shared string editor's format. Only public string/llm declarations and exact Start string selectors prove this conversion. Existing drafts, unknown sources and other typed inputs are untouched. The caller supplies its existing placeholder grammar; no runtime lookup occurs. """ from collections import Counter from collections.abc import Mapping, Sequence from re import Pattern from services.dify_builder.native_api_output_contract import is_native_api_tool _START_STRING_TYPES = frozenset({"text-input", "paragraph", "select"}) def normalize_native_api_string_bindings( *, tool_data: Mapping[str, object], nodes: Sequence[Mapping[str, object]], parameters: Sequence[Mapping[str, object]], placeholder_pattern: Pattern[str], ) -> dict[str, object]: """Return bindings with proven direct Start strings encoded as mixed text. Equivalence concerns present string values. Native mixed rendering already differs from variable lookup for absent/null pool values; this owner neither changes those runtime semantics nor infers types of other node outputs. Sources, declarations and original binding objects are never mutated. """ raw = tool_data.get("tool_parameters") bindings: dict[str, object] = dict(raw) if isinstance(raw, Mapping) else {} if not is_native_api_tool(tool_data): return bindings node_counts = Counter(node.get("id") for node in nodes if isinstance(node.get("id"), str)) string_selectors: set[tuple[str, str]] = set() for node in nodes: node_id, data = node.get("id"), node.get("data") if not isinstance(node_id, str) or node_counts[node_id] != 1 or not isinstance(data, Mapping): continue raw_variables = data.get("variables") if data.get("type") != "start" or not isinstance(raw_variables, list): continue variables = [item for item in raw_variables if isinstance(item, Mapping)] counts = Counter(item.get("variable") for item in variables if isinstance(item.get("variable"), str)) for variable in variables: name = variable.get("variable") kind = variable.get("type") if isinstance(name, str) and counts[name] == 1 and isinstance(kind, str) and kind in _START_STRING_TYPES: string_selectors.add((node_id, name)) parameter_counts = Counter(item.get("name") for item in parameters if isinstance(item.get("name"), str)) for parameter in parameters: name = parameter.get("name") if ( not isinstance(name, str) or parameter_counts[name] != 1 or parameter.get("type") != "string" or parameter.get("form") != "llm" ): continue binding = bindings.get(name) if not isinstance(binding, Mapping) or binding.get("type") != "variable": continue selector = binding.get("value") if not isinstance(selector, list) or len(selector) != 2 or not all(isinstance(part, str) for part in selector): continue source_id, field = selector assert isinstance(source_id, str) assert isinstance(field, str) # A dot inside the direct field would become a different nested selector # when the native template resolver splits its placeholder on dots. if "." in field or (source_id, field) not in string_selectors: continue placeholder = "{{#" + source_id + "." + field + "#}}" if placeholder_pattern.fullmatch(placeholder) is not None: bindings[name] = {**binding, "type": "mixed", "value": placeholder} return bindings