Skip to content

Commit

Permalink
Adds full documentation on sphinx
Browse files Browse the repository at this point in the history
See docs/README-internal.md
  • Loading branch information
elijahbenizzy committed Feb 9, 2024
1 parent c861d79 commit b593431
Show file tree
Hide file tree
Showing 44 changed files with 1,236 additions and 32 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: documentation

on: [push]

permissions:
contents: write

jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
- name: Install dependencies
run: |
pip install -e ".[documentation]"
- name: Sphinx build
run: |
sphinx-build docs _build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
# if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/initial-prototypes' }}
with:
publish_branch: gh-pages
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: _build/
force_orphan: true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#
_build
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ Let us take a look at of how one might design a gpt-like chatbot. It:
6. If this succeeds, we present the response to the user
7. Await a new prompt, GOTO (1)

Simple, right? Until you start tracking how state flows through:
- The prompt is referenced by multiple future steps
- The chat history is referred to at multiple points, and appended to (at (1) and (5))
- The decision on mode is opaque, and referred to both by (4), to know what model to query and by (6) to know how to render the response
- You will likely want to add more capabilities, more retries, etc...

Chatbots, while simple at first glance, turn into something of a beast when you want to bring them to production and understand exactly *why*
they make the decisions they do.

We can model this as a _State Machine_, using the following two concepts:
1. `Actions` -- a function that has two jobs. These form Nodes.
Expand All @@ -36,6 +28,17 @@ We can model this as a _State Machine_, using the following two concepts:

The set of these together form what we will call a `Application` (effectively) a graph.

Why do we need all this abstraction?

This is all simple until you start tracking how state flows through:
- The prompt is referenced by multiple future steps
- The chat history is referred to at multiple points, and appended to (at (1) and (5))
- The decision on mode is opaque, and referred to both by (4), to know what model to query and by (6) to know how to render the response
- You will likely want to add more capabilities, more retries, etc...

Chatbots, while simple at first glance, turn into something of a beast when you want to bring them to production and understand exactly *why*
they make the decisions they do.

For those into the CS details, this is reverse to how a state machine is usually represented
(edges are normally actions and nodes are normally state). We've found this the easiest way to
express computation as simple python functions.
Expand Down
98 changes: 83 additions & 15 deletions burr/core/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,45 @@


class Function(abc.ABC):
"""Interface to represent the 'computing' part of an action"""

@property
@abc.abstractmethod
def reads(self) -> list[str]:
"""Returns the keys from the state that this function reads
:return: A list of keys
"""
pass

@abc.abstractmethod
def run(self, state: State) -> dict:
pass
"""Runs the function on the given state and returns the result.
The result is jsut a key/value dictionary.
def is_async(self):
:param state: State to run the function on
:return: Result of the function
"""

def is_async(self) -> bool:
"""Convenience method to check if the function is async or not.
This can be used by the application to run it.
:return: True if the function is async, False otherwise
"""
return inspect.iscoroutinefunction(self.run)


class Reducer(abc.ABC):
"""Interface to represent the 'updating' part of an action"""

@property
@abc.abstractmethod
def writes(self) -> list[str]:
"""Returns the keys from the state that this reducer writes
:return: A list of keys
"""
pass

@abc.abstractmethod
Expand All @@ -35,15 +57,8 @@ def update(self, result: dict, state: State) -> State:

class Action(Function, Reducer, abc.ABC):
def __init__(self):
"""Represents an action in a state machine. This is the base class from which:
1. Custom actions
2. Conditions
3. Results
All extend this class. Note that name is optional so that APIs can set the
name on these actions as part of instantiation.
When they're used, they must have a name set.
"""Represents an action in a state machine. This is the base class from which
actions extend. Note that this class needs to have a name set after the fact.
"""
self._name = None

Expand Down Expand Up @@ -72,7 +87,7 @@ def with_name(self, name: str) -> "Action":
@property
def name(self) -> str:
"""Gives the name of this action. This should be unique
across your agent."""
across your application."""
return self._name

def __repr__(self):
Expand All @@ -85,13 +100,28 @@ class Condition(Function):
KEY = "PROCEED"

def __init__(self, keys: List[str], resolver: Callable[[State], bool], name: str = None):
"""Base condition class. Chooses keys to read from the state and a resolver function.
:param keys: Keys to read from the state
:param resolver: Function to resolve the condition to True or False
:param name: Name of the condition
"""
self._resolver = resolver
self._keys = keys
self._name = name

@staticmethod
def expr(expr: str) -> "Condition":
"""Returns a condition that evaluates the given expression"""
"""Returns a condition that evaluates the given expression. Expression must use
only state variables and Python operators. Do not trust that anything else will work.
Do not accept expressions generated from user-inputted text, this has the potential to be unsafe.
You can also refer to this as ``from burr.core import expr`` in the API.
:param expr: Expression to evaluate
:return: A condition that evaluates the given expression
"""
tree = ast.parse(expr, mode="eval")

# Visitor class to collect variable names
Expand Down Expand Up @@ -124,7 +154,13 @@ def reads(self) -> list[str]:
@classmethod
def when(cls, **kwargs):
"""Returns a condition that checks if the given keys are in the
state and equal to the given values."""
state and equal to the given values.
You can also refer to this as ``from burr.core import when`` in the API.
:param kwargs: Keyword arguments of keys and values to check -- will be an AND condition
:return: A condition that checks if the given keys are in the state and equal to the given values
"""
keys = list(kwargs.keys())

def condition_func(state: State) -> bool:
Expand All @@ -136,18 +172,34 @@ def condition_func(state: State) -> bool:
name = f"{', '.join(f'{key}={value}' for key, value in sorted(kwargs.items()))}"
return Condition(keys, condition_func, name=name)

@classmethod
@property
def default(self) -> "Condition":
"""Returns a default condition that always resolves to True.
You can also refer to this as ``from burr.core import default`` in the API.
:return: A default condition that always resolves to True
"""
return Condition([], lambda _: True, name="default")

@property
def name(self) -> str:
return self._name


default = Condition([], lambda _: True, name="default")
default = Condition.default
when = Condition.when
expr = Condition.expr


class Result(Action):
def __init__(self, fields: list[str]):
"""Represents a result action. This is purely a convenience class to
pull data from state and give it out to the result. It does nothing to
the state itself.
:param fields: Fields to pull from the state
"""
super(Result, self).__init__()
self._fields = fields

Expand Down Expand Up @@ -272,6 +324,22 @@ def bind(self, **kwargs: Any):


def bind(self: FunctionRepresentingAction, **kwargs: Any) -> FunctionRepresentingAction:
"""Binds an action to the given parameters. This is functionally equivalent to
functools.partial, but is more explicit and is meant to be used in the API. This only works with
the functional API for ``@action`` and not with the class-based API.
.. code-block:: python
@action(["x"], ["y"])
def my_action(state: State, z: int) -> Tuple[dict, State]:
return {"y": state.get("x") + z}, state
my_action.bind(z=2)
:param self: The decorated function
:param kwargs: The keyword arguments to bind
:return: The decorated function with the given parameters bound
"""
self.action_function = self.action_function.with_params(**kwargs)
return self

Expand Down
14 changes: 7 additions & 7 deletions burr/core/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def step(self) -> Optional[Tuple[Action, dict, State]]:
Use this if you just want to do something with the state and not rely on generators.
E.G. press forward/backwards, hnuman in the loop, etc... Odds are this is not
the method you want -- you'll want iterate() (if you want to see the state/
the method you want -- you'll want iterate() (if you want to see the state/
results along the way), or run() (if you just want the final state/results).
:return: Tuple[Function, dict, State] -- the function that was just ran, the result of running it, and the new state
Expand Down Expand Up @@ -439,13 +439,13 @@ def _validate_actions(actions: Optional[List[Action]]):
raise ValueError("Must have at least one action in the application!")


@dataclasses.dataclass
class ApplicationBuilder:
state: State = dataclasses.field(default_factory=State)
transitions: List[Tuple[str, str, Condition]] = None
actions: List[Action] = None
start: str = None
lifecycle_adapters: List[LifecycleAdapter] = dataclasses.field(default_factory=list)
def __init__(self):
self.state: State = State()
self.transitions: Optional[List[Tuple[str, str, Condition]]] = None
self.actions: Optional[List[Action]] = None
self.start: Optional[str] = None
self.lifecycle_adapters: List[LifecycleAdapter] = list()

def with_state(self, **kwargs) -> "ApplicationBuilder":
if self.state is not None:
Expand Down
2 changes: 1 addition & 1 deletion burr/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class State(Mapping):
2. Pulling/pushing to external places
3. Simultaneous writes/reads in the case of parallelism
4. Schema enforcement -- how to specify/manage? Should this be a
dataclass when implemented?
dataclass when implemented?
"""

def __init__(self, initial_values: Dict[str, Any] = None):
Expand Down
1 change: 1 addition & 0 deletions burr/integrations/hamilton.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def writes(self) -> list[str]:
return [source.key for source in self._outputs.values()]

def visualize_step(self, **kwargs):
"""Visualizes execution for a Hamilton step"""
dr = self._driver
inputs = {key: ... for key in self._inputs}
overrides = inputs
Expand Down
36 changes: 36 additions & 0 deletions burr/lifecycle/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,38 @@

@lifecycle.base_hook("pre_run_step")
class PreRunStepHook(abc.ABC):
"""Hook that runs before a step is executed"""

@abc.abstractmethod
def pre_run_step(self, *, state: "State", action: "Action", **future_kwargs: Any):
"""Run before a step is executed.
:param state: State prior to step execution
:param action: Action to be executed
:param future_kwargs: Future keyword arguments
"""
pass


@lifecycle.base_hook("pre_run_step")
class PreRunStepHookAsync(abc.ABC):
"""Async hook that runs before a step is executed"""

@abc.abstractmethod
async def pre_run_step(self, *, state: "State", action: "Action", **future_kwargs: Any):
"""Async run before a step is executed.
:param state: State prior to step execution
:param action: Action to be executed
:param future_kwargs: Future keyword arguments
"""
pass


@lifecycle.base_hook("post_run_step")
class PostRunStepHook(abc.ABC):
"""Hook that runs after a step is executed"""

@abc.abstractmethod
def post_run_step(
self,
Expand All @@ -34,11 +52,21 @@ def post_run_step(
exception: Exception,
**future_kwargs: Any,
):
"""Run after a step is executed.
:param state: State after step execution
:param action: Action that was executed
:param result: Result of the action
:param exception: Exception that was raised
:param future_kwargs: Future keyword arguments
"""
pass


@lifecycle.base_hook("post_run_step")
class PostRunStepHookAsync(abc.ABC):
"""Async hook that runs after a step is executed"""

@abc.abstractmethod
async def post_run_step(
self,
Expand All @@ -49,6 +77,14 @@ async def post_run_step(
exception: Exception,
**future_kwargs: Any,
):
"""Async run after a step is executed
:param state: State after step execution
:param action: Action that was executed
:param result: Result of the action
:param exception: Exception that was raised
:param future_kwargs: Future keyword arguments
"""
pass


Expand Down
Loading

0 comments on commit b593431

Please sign in to comment.