diff --git a/guidance/_grammar.py b/guidance/_grammar.py index 7500b54a3..8b63df031 100644 --- a/guidance/_grammar.py +++ b/guidance/_grammar.py @@ -966,10 +966,6 @@ def _re_with_temperature(grammar, temperature, visited_set): # return ModelVariable(name) -def active_role_end() -> ModelVariable: - return ModelVariable("active_role_end") - - def eos_token() -> ModelVariable: return ModelVariable("eos_token") diff --git a/guidance/library/_gen.py b/guidance/library/_gen.py index 71b969e8c..775843103 100644 --- a/guidance/library/_gen.py +++ b/guidance/library/_gen.py @@ -8,7 +8,7 @@ from ._any_char import any_char from .._grammar import capture from ._regex import regex as regex_grammar -from .._grammar import token_limit, eos_token, active_role_end, with_temperature +from .._grammar import token_limit, eos_token, with_temperature from ._tool import Tool from ._block import block @@ -129,7 +129,7 @@ def gen( if isinstance(stop, str): stop = [stop] if regex is None: - stop = stop + [select([eos_token(), active_role_end()])] + stop = stop + [eos_token()] if stop_regex is None: stop_regex = [] diff --git a/guidance/library/_role.py b/guidance/library/_role.py index 843ed904e..737d86a56 100644 --- a/guidance/library/_role.py +++ b/guidance/library/_role.py @@ -1,27 +1,16 @@ from .._guidance import guidance -from ._block import block +from ._block import ContextBlock from ._set_attribute import set_attribute -nodisp_start = "<||_#NODISP_||>" -nodisp_end = "<||_/NODISP_||>" -span_start = "<||_html:_||>" -span_end = "<||_html:_||>" -@guidance -def role_opener(lm, role_name, **kwargs): - indent = getattr(lm, "indent_roles", True) +class RoleBlock(ContextBlock): + def __init__(self, role_name, opener, closer, name=None): + super().__init__(opener, closer, name=name) + self.role_name = role_name - # Block start container (centers elements) - if indent: - lm += f"<||_html:
" - + display_out - + "" - ) - return display_out def _send_to_event_queue(self, value): """For streaming in code. @@ -924,6 +880,7 @@ def copy(self): new_lm._variables = self._variables.copy() new_lm._variables_log_probs = self._variables_log_probs.copy() new_lm.opened_blocks = self.opened_blocks.copy() + new_lm._state = self._state.copy() # create a new clean event queue new_lm._event_queue = None # we start with no event queue because nobody is listening to us yet @@ -938,7 +895,7 @@ def copy(self): return new_lm - def _inplace_append(self, value, force_silent=False): + def _inplace_append(self, obj: Object, force_silent: bool = False): """This is the base way to add content to the current LM object that is being constructed. All updates to the model state should eventually use this function. @@ -951,7 +908,7 @@ def _inplace_append(self, value, force_silent=False): """ # update the byte state - self._state += str(value) # TODO: make _state to be bytes not a string + self._state.append(obj) # see if we should update the display if not force_silent: @@ -995,20 +952,30 @@ def reset(self, clear_variables=True): self._variables_log_probs = {} return self + def _html(self): + out = self._state._html() + for context in reversed(self.opened_blocks): + _, closer = self.opened_blocks[context] + if closer is not None: + out += closer._html() + return out + def _repr_html_(self): if ipython_is_imported: clear_output(wait=True) return self._html() - def _current_prompt(self): + def _current_prompt(self) -> str: """The current prompt in bytes (which is the state without the context close tags).""" - return format_pattern.sub("", self._state) + return str(self._state) def __str__(self): """A string representation of the current model object (that includes context closers).""" - out = self._current_prompt() + out = str(self._state) for context in reversed(self.opened_blocks): - out += format_pattern.sub("", self.opened_blocks[context][1]) + _, closer = self.opened_blocks[context] + if closer is not None: + out += str(closer) return out def __add__(self, value): @@ -1019,6 +986,8 @@ def __add__(self, value): value : guidance grammar The grammar used to extend the current model. """ + # Import in function to guard against circular import + from ..library._role import RoleBlock # create the new lm object we will return # (we need to do this since Model objects are immutable) @@ -1034,7 +1003,7 @@ def __add__(self, value): new_blocks.append(context) # mark this so we don't re-add when computing the opener or closer (even though we don't know the close text yet) - lm.opened_blocks[context] = (0, "") + lm.opened_blocks[context] = (0, None) # find what old blocks need to be removed old_blocks = [] @@ -1046,20 +1015,44 @@ def __add__(self, value): del lm.opened_blocks[context] # close any newly closed contexts - for (pos, close_text), context in old_blocks: + for (pos, closer), context in old_blocks: + assert closer is not None if context.name is not None: - lm._variables[context.name] = format_pattern.sub( - "", lm._state[pos:] - ) - lm += context.closer + # Capture + lm._variables[context.name] = str(lm._state[pos:]) + lm._inplace_append(closer) # apply any newly opened contexts (new from this object's perspective) for context in new_blocks: - lm += context.opener + if isinstance(context, RoleBlock): + # Apply the opener (a grammar) + with grammar_only(): + # TODO: be careful about the temp lm's display? (e.g. with silent()) + tmp = lm + context.opener + open_text = str(tmp._state[len(lm._state):]) # get the new state added by calling the opener + # Add that new state as text in a RoleOpener + lm._inplace_append( + RoleOpener( + role_name=context.role_name, + text=open_text, + indent=getattr(lm, "indent_roles", True) + ) + ) + else: + lm += context.opener with grammar_only(): + # TODO: be careful about the temp lm's display? (e.g. with silent()) tmp = lm + context.closer - close_text = tmp._state[len(lm._state):] # get the new state added by calling the closer - lm.opened_blocks[context] = (len(lm._state), close_text) + close_text = str(tmp._state[len(lm._state):]) # get the new state added by calling the closer + if isinstance(context, RoleBlock): + closer = RoleCloser( + role_name=context.role_name, + text=close_text, + indent=getattr(lm, "indent_roles", True) + ) + else: + closer = Text(text=close_text) + lm.opened_blocks[context] = (len(lm._state), closer) # clear out names that we override if context.name is not None: @@ -1075,7 +1068,9 @@ def __add__(self, value): # we have no embedded objects if len(parts) == 1: - lm._inplace_append(value) + lm._inplace_append( + Text(text=value) + ) out = lm # if we have embedded objects we have to convert the string to a grammar tree @@ -1119,7 +1114,8 @@ def __add__(self, value): ) # this flushes the display - out._inplace_append("") + # TODO: directly call _update_display? + out._inplace_append(Text(text="")) return out @@ -1147,9 +1143,8 @@ def __getitem__(self, key): else: for context in list(reversed(self.opened_blocks)): if context.name == key: - return format_pattern.sub( - "", self._state[self.opened_blocks[context][0] :] - ) + pos, _ = self.opened_blocks[context] + return str(self._state[pos:]) raise KeyError(f"Model does not contain the variable '{key}'") @@ -1328,11 +1323,19 @@ def _run_stateless(self, stateless_function, temperature=0.0, top_p=1.0, n=1): if len(chunk.new_bytes) > 0: generated_value += new_text if chunk.is_generated: - lm += f"<||_html:_||>" - lm += new_text - if chunk.is_generated: - lm += "<||_html:_||>" - + self._inplace_append( + Text( + text = new_text, + # TODO: this will be slightly wrong if we have a delayed byte string + probability = chunk.new_bytes_prob + ) + ) + else: + self._inplace_append( + Text( + text = new_text, + ) + ) # last_is_generated = chunk.is_generated if len(chunk.capture_groups) > 0: diff --git a/guidance/models/_model_state.py b/guidance/models/_model_state.py new file mode 100644 index 000000000..72cd02940 --- /dev/null +++ b/guidance/models/_model_state.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, overload +import base64 +import html + + +@dataclass(frozen=True, slots=True) +class Object: + def _html(self) -> str: + raise NotImplementedError + + def __str__(self) -> str: + raise NotImplementedError + + +@dataclass(frozen=True, slots=True) +class Text(Object): + text: str + probability: Optional[float] = None + + def __str__(self) -> str: + return self.text + + def _html(self) -> str: + escaped_text = html.escape(self.text) + if self.probability is not None: + style = f"background-color: rgba({165*(1-self.probability) + 0}, {165*self.probability + 0}, 0, {0.15}); border-radius: 3px;" + return f"{escaped_text}" + return escaped_text + + +@dataclass(frozen=True, slots=True) +class Image(Object): + id: str + data: bytes + + def __str__(self) -> str: + raise NotImplementedError + + def _html(self) -> str: + return f"""""" + + +@dataclass(frozen=True, slots=True) +class RoleOpener(Object): + role_name: str + text: str + indent: bool + + def _html(self) -> str: + out = "" + if self.indent: + out += f"
" + for obj in self.objects: + out += obj._html() + out += "" + return out