"""Convert hassil templates to finite state transducers (FSTs).""" import itertools from collections import defaultdict, deque from collections.abc import Collection from dataclasses import dataclass, field from enum import Enum, auto from typing import Deque, Dict, List, Optional, Set, TextIO, Tuple from hassil import ( Alternative, Expression, Group, IntentData, Intents, ListReference, Permutation, RangeSlotList, RuleReference, Sentence, Sequence, SlotList, TextChunk, TextSlotList, ) EPS = "" SPACE = "" UNK = "" class SuppressOutput(Enum): DISABLED = auto() UNTIL_END = auto() UNTIL_SPACE = auto() @dataclass class ListReferenceNode: list_names: List[Tuple[str, Optional[Set[str]]]] = field(default_factory=list) children: "List[ListReferenceNode]" = field(default_factory=list) @dataclass class FstArc: to_state: int in_label: str = EPS out_label: str = EPS log_prob: Optional[float] = None @dataclass class Fst: arcs: Dict[int, List[FstArc]] = field(default_factory=lambda: defaultdict(list)) states: Set[int] = field(default_factory=lambda: {0}) final_states: Set[int] = field(default_factory=set) words: Set[str] = field(default_factory=set) output_words: Set[str] = field(default_factory=set) start: int = 0 current_state: int = 0 def next_state(self) -> int: self.states.add(self.current_state) self.current_state += 1 return self.current_state def next_edge( self, from_state: int, in_label: Optional[str] = None, out_label: Optional[str] = None, log_prob: Optional[float] = None, ) -> int: to_state = self.next_state() self.add_edge(from_state, to_state, in_label, out_label, log_prob) return to_state def add_edge( self, from_state: int, to_state: int, in_label: Optional[str] = None, out_label: Optional[str] = None, log_prob: Optional[float] = None, ) -> None: if in_label is None: in_label = EPS if out_label is None: out_label = in_label if (" " in in_label) or (" " in out_label): raise ValueError( f"Cannot have white space in labels: from={in_label}, to={out_label}" ) if (not in_label) or (not out_label): raise ValueError(f"Labels cannot be empty: from={in_label}, to={out_label}") if in_label != EPS: self.words.add(in_label) if out_label != EPS: self.output_words.add(out_label) self.states.add(from_state) self.states.add(to_state) self.arcs[from_state].append(FstArc(to_state, in_label, out_label, log_prob)) def accept(self, state: int) -> None: self.states.add(state) self.final_states.add(state) def write(self, fst_file: TextIO, symbols_file: Optional[TextIO] = None) -> None: symbols = {EPS: 0, UNK: 1} for state, arcs in self.arcs.items(): for arc in arcs: if arc.in_label not in symbols: symbols[arc.in_label] = len(symbols) if arc.out_label not in symbols: symbols[arc.out_label] = len(symbols) if arc.log_prob is None: print( state, arc.to_state, arc.in_label, arc.out_label, file=fst_file ) else: print( state, arc.to_state, arc.in_label, arc.out_label, arc.log_prob, file=fst_file, ) for state in self.final_states: print(state, file=fst_file) if symbols_file is not None: for symbol, symbol_id in symbols.items(): print(symbol, symbol_id, file=symbols_file) def remove_spaces(self) -> "Fst": """Remove tokens and merge partial word labels.""" visited: Dict[Tuple[int, int, int], int] = {} fst_without_spaces = Fst() for arc in self.arcs[self.start]: # Copy initial weighted intent arc output_state = fst_without_spaces.next_edge( fst_without_spaces.start, out_label=arc.out_label, log_prob=arc.log_prob ) for next_arc_idx, next_arc in enumerate(self.arcs[arc.to_state]): self._remove_spaces( arc.to_state, next_arc, next_arc_idx, "", None, visited, fst_without_spaces, output_state, ) return fst_without_spaces def _remove_spaces( self, state: int, arc: FstArc, arc_idx: int, word: str, output_word: Optional[str], visited: Dict[Tuple[int, int, int], int], fst_without_spaces: "Fst", output_state: int, suppress_output: SuppressOutput = SuppressOutput.DISABLED, ) -> None: if arc.in_label == SPACE: key = (state, arc.to_state, arc_idx) cached_state = visited.get(key) input_symbol = word or EPS output_symbol = input_symbol if suppress_output in ( SuppressOutput.UNTIL_END, SuppressOutput.UNTIL_SPACE, ): # Suppress output output_symbol = output_word or EPS output_word = None # consume elif output_word is not None: # Override output output_symbol = output_word output_word = None # consume if cached_state is not None: fst_without_spaces.add_edge( output_state, cached_state, input_symbol, output_symbol, # log_prob=WORD_PENALTY if input_symbol != EPS else None, ) return output_state = fst_without_spaces.next_edge( output_state, input_symbol, output_symbol, # log_prob=WORD_PENALTY if input_symbol != EPS else None, ) visited[key] = output_state if arc.to_state in self.final_states: fst_without_spaces.final_states.add(output_state) word = "" if suppress_output == SuppressOutput.UNTIL_SPACE: suppress_output = SuppressOutput.DISABLED elif arc.in_label != EPS: word += arc.in_label if ( (suppress_output == SuppressOutput.DISABLED) and (arc.out_label != EPS) and (arc.out_label != arc.in_label) ): # Short-term output override suppress_output = SuppressOutput.UNTIL_SPACE output_word = arc.out_label for next_arc_idx, next_arc in enumerate(self.arcs[arc.to_state]): self._remove_spaces( arc.to_state, next_arc, next_arc_idx, word, output_word, visited, fst_without_spaces, output_state, suppress_output=suppress_output, ) def prune(self) -> None: """Remove paths not connected to a final state.""" while True: states_to_prune: Set[int] = set() for state in self.states: if (not self.arcs[state]) and (state not in self.final_states): states_to_prune.add(state) if not states_to_prune: break self.states.difference_update(states_to_prune) # Prune outgoing arcs for state in states_to_prune: self.arcs.pop(state, None) # Prune incoming arcs for state in self.states: needs_pruning = any( arc.to_state in states_to_prune for arc in self.arcs[state] ) if needs_pruning: self.arcs[state] = [ arc for arc in self.arcs[state] if arc.to_state not in states_to_prune ] def to_strings(self, add_spaces: bool) -> List[str]: strings: List[str] = [] self._to_strings("", strings, self.start, add_spaces) return strings def _to_strings(self, text: str, strings: List[str], state: int, add_spaces: bool): if state in self.final_states: text_norm = " ".join(text.strip().split()) if text_norm: strings.append(text_norm) for arc in self.arcs[state]: if arc.in_label == SPACE: arc_text = text + " " elif arc.in_label != EPS: if add_spaces: arc_text = text + " " + arc.in_label else: arc_text = text + arc.in_label else: # Skip arc_text = text self._to_strings(arc_text, strings, arc.to_state, add_spaces) def to_tokens(self, only_connected: bool = True) -> List[List[str]]: tokens: List[List[str]] = [] self._to_tokens([], tokens, self.start, only_connected) # Remove final spaces for path in tokens: if path and (path[-1] == SPACE): path.pop() return tokens def _to_tokens( self, path: List[str], tokens: List[List[str]], state: int, only_connected: bool, ): if (state in self.final_states) and path: tokens.append(path) has_arcs = False for arc in self.arcs[state]: has_arcs = True # Skip and initial if (arc.in_label == EPS) or (arc.in_label == SPACE and (not path)): arc_path = path else: arc_path = path + [arc.in_label.strip()] self._to_tokens(arc_path, tokens, arc.to_state, only_connected) if path and (not has_arcs) and (not only_connected): # Dead path tokens.append(path) def expression_to_fst( expression: Expression, state: int, fst: Fst, intent_data: IntentData, intents: Intents, list_ref_node: ListReferenceNode, slot_lists: Optional[Dict[str, SlotList]] = None, ) -> Optional[int]: if isinstance(expression, TextChunk): chunk: TextChunk = expression space_before = False space_after = False if chunk.original_text == " ": return fst.next_edge(state, SPACE) if chunk.original_text.startswith(" "): space_before = True if chunk.original_text.endswith(" "): space_after = True word = chunk.original_text.strip() if not word: return state if space_before: state = fst.next_edge(state, SPACE) sub_words = word.split() last_sub_word_idx = len(sub_words) - 1 for sub_word_idx, sub_word in enumerate(sub_words): if isinstance(sub_word, str): sub_output_word: Optional[str] = sub_word else: sub_word, sub_output_word = sub_word sub_output_word = sub_output_word or EPS state = fst.next_edge(state, sub_word, sub_output_word) if sub_word_idx != last_sub_word_idx: # Add spaces between words state = fst.next_edge(state, SPACE) if space_after: state = fst.next_edge(state, SPACE) return state if isinstance(expression, Group): grp: Group = expression if isinstance(grp, Alternative): start = state end = fst.next_state() for item in grp.items: item_list_ref_node = ListReferenceNode() list_ref_node.children.append(item_list_ref_node) maybe_state = expression_to_fst( item, start, fst, intent_data, intents, item_list_ref_node, slot_lists, ) if maybe_state is None: # Dead branch continue state = maybe_state if state == start: # Empty item continue fst.add_edge(state, end) if grp.is_optional: fst.add_edge(start, end) return end if isinstance(grp, Sequence): for item in grp.items: maybe_state = expression_to_fst( item, state, fst, intent_data, intents, list_ref_node, slot_lists, ) if maybe_state is None: # Dead branch return None state = maybe_state return state if isinstance(grp, Permutation): # a;b -> (a b|b a) return expression_to_fst( Alternative( [ Sequence(perm_items) # type: ignore[arg-type] for perm_items in itertools.permutations(grp.items) ] ), state, fst, intent_data, intents, list_ref_node, slot_lists, ) raise ValueError(f"Unexpected group type: {grp}") if isinstance(expression, ListReference): # {list} list_ref: ListReference = expression slot_list: Optional[SlotList] = None if slot_lists is not None: slot_list = slot_lists.get(list_ref.list_name) if slot_list is None: slot_list = intent_data.slot_lists.get(list_ref.list_name) if slot_list is None: slot_list = intents.slot_lists.get(list_ref.list_name) if isinstance(slot_list, TextSlotList) or ( (slot_list is None) and (list_ref.slot_name in ("name", "area", "floor")) ): list_name = list_ref.slot_name domains: Optional[Set[str]] = None if (list_name == "name") and (intent_data.requires_context is not None): requires_domain = intent_data.requires_context.get("domain") if requires_domain: if isinstance(requires_domain, str): requires_domain = [requires_domain] domains = set(requires_domain) list_ref_node.list_names.append((list_name, domains)) return expression_to_fst( TextChunk(f"{{{list_name}}}"), state, fst, intent_data, intents, list_ref_node, slot_lists, ) if isinstance(slot_list, RangeSlotList): list_name = list_ref.slot_name list_ref_node.list_names.append((list_name, None)) return expression_to_fst( TextChunk(f"{{{list_name}}}"), state, fst, intent_data, intents, list_ref_node, slot_lists, ) word = f"{{{list_ref.list_name}}}" fst.next_edge(state, word, word) return None if isinstance(expression, RuleReference): # rule_ref: RuleReference = expression rule_body: Optional[Sentence] = intent_data.expansion_rules.get( rule_ref.rule_name ) if rule_body is None: rule_body = intents.expansion_rules.get(rule_ref.rule_name) if rule_body is None: raise ValueError(f"Missing expansion rule <{rule_ref.rule_name}>") return expression_to_fst( rule_body.expression, state, fst, intent_data, intents, list_ref_node, slot_lists, ) return state def intents_to_fst( intents: Intents, slot_lists: Optional[Dict[str, SlotList]] = None, intent_names: Optional[Collection[str]] = None, ) -> Fst: fst_with_spaces = Fst() final = fst_with_spaces.next_state() skip_alt_start = Alternative( items=[TextChunk(" "), TextChunk("")], is_optional=True ) skip_alt_end = Alternative( items=[TextChunk(" "), TextChunk("")], is_optional=True ) intent_slot_combos = defaultdict(set) for intent_name, intent in intents.intents.items(): if intent_names and (intent_name not in intent_names): continue intent_start_state = fst_with_spaces.next_edge( fst_with_spaces.start, EPS, f"intent:{intent_name}" ) for data in intent.data: for sentence in data.sentences: sentence_state = fst_with_spaces.next_edge( # fst_with_spaces.start, intent_start_state, SPACE, SPACE, ) list_ref_node = ListReferenceNode() state = expression_to_fst( Sequence(items=[skip_alt_start, sentence.expression, skip_alt_end]), # sentence.expression, sentence_state, fst_with_spaces, data, intents, list_ref_node, slot_lists, ) if state is None: # Dead branch continue fst_with_spaces.add_edge(state, final, SPACE, SPACE) q: Deque[Tuple[ListReferenceNode, List[str]]] = deque( [(list_ref_node, [])] ) while q: node, slots = q.popleft() if node.list_names: slots.extend(n[0] for n in node.list_names) if not node.children: intent_slot_combos[intent_name].add(tuple(sorted(slots))) continue for child_node in node.children: q.append((child_node, list(slots))) fst_with_spaces.accept(final) return fst_with_spaces