From c5e703a2e3912798d40612d8c73132077b3ba13a Mon Sep 17 00:00:00 2001 From: Adrian Lyjak Date: Sat, 27 Sep 2025 00:38:41 -0400 Subject: [PATCH] fix actions (#3) --- README.md | 54 +++++++- copier.yaml | 5 + pyproject.toml | 31 +++++ src/basic/__init__.py | 0 src/basic/workflow.py | 172 ++++++++++++++++++++++++++ {{ _copier_conf.answers_file }}.jinja | 2 + 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 copier.yaml create mode 100644 pyproject.toml create mode 100644 src/basic/__init__.py create mode 100644 src/basic/workflow.py create mode 100644 {{ _copier_conf.answers_file }}.jinja diff --git a/README.md b/README.md index 0b5d9f9..e39cdf4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ -# template-workflow-basic -Llama Index Workflow Template +# Email Workflow Example + +This example demonstrates how to build an event-driven, async-first workflow for sending emails using [llama-index-workflows](https://github.com/run-llama/llama-index-workflows). The workflow uses an LLM to generate email content and sends emails to internal recipients only. + +## Installation + +Install all required dependencies (including llama-index-workflows and OpenAI LLM support): + +```bash +pip install -e . +``` + +## Usage + +Run the workflow from the command line: + +```bash +python -m basic.workflow \ + --sender you@mycompany.com \ + --receiver recipient1@mycompany.com \ + --receiver recipient2@mycompany.com \ + --subject "Quarterly Update" \ + --draft "Here's a draft for the quarterly update email." +``` + +**Note:** + +- The sender and all receivers must use `@mycompany.com` emails. +- You must set your `OPENAI_API_KEY` in the environment before running. + +## Workflow Overview + +- **prepare_email**: + Initializes the email client and uses an LLM to generate a fully-formed email from your draft and subject. + Emits a `PrepareEmail` event for each receiver. + +- **send_email**: + Sends the generated email to each receiver using the internal email client. + Updates email statistics. + +- **collect_email_stats**: + Collects results from all send attempts and outputs a summary of successes and failures. + +## Customization + +- Replace the `EmailClient` logic with your own email sending implementation. +- Extend the workflow with additional steps or validation as needed. + +## References + +- [llama-index-workflows documentation](https://github.com/run-llama/llama-index-workflows) +- [OpenAI LLM integration](https://github.com/run-llama/llama-index-llms-openai) diff --git a/copier.yaml b/copier.yaml new file mode 100644 index 0000000..0f48d31 --- /dev/null +++ b/copier.yaml @@ -0,0 +1,5 @@ +_exclude: + - ".git" + - ".github" + - "copier.yaml" + - ".venv" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4acd8d5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "basic" +version = "0.1.0" +description = "A base example that showcases usage patterns for workflows" +requires-python = ">=3.10" +readme = "README.md" +dependencies = ["llama-index-workflows", "llama-index-llms-openai"] + +[tool.hatch.envs.default.scripts] +format = "ruff format ." +format-check = "ruff format --check ." +lint = "ruff check --fix ." +lint-check = ["ruff check ."] +typecheck = "ty check src" +test = "pytest" +all-check = ["format-check", "lint-check", "test"] +all-fix = ["format", "lint", "test"] + +[dependency-groups] +dev = ["hatch>=1.14.2", "pytest>=8.4.2", "ruff>=0.13.2", "ty>=0.0.1a21"] + + +[tool.llamadeploy] +env_files = [".env"] + +[tool.llamadeploy.workflows] +default = "basic.workflow:workflow" diff --git a/src/basic/__init__.py b/src/basic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/basic/workflow.py b/src/basic/workflow.py new file mode 100644 index 0000000..b524f36 --- /dev/null +++ b/src/basic/workflow.py @@ -0,0 +1,172 @@ +from pydantic import BaseModel, ConfigDict +from workflows import Workflow, step, Context +from workflows.events import ( + Event, + StartEvent, + StopEvent, +) +from typing import Annotated, Optional +from workflows.resource import Resource +from llama_index.llms.openai import OpenAI + + +# replace with an actual email sending client +class EmailClient: + def __init__(self, sender_email: str): + if not self._internal_email(sender_email): + print("Sorry, you are not allowed to use this email client") + return + self.sender_email = sender_email + + def send(self, receiver_email: str, subject: str, content: str) -> bool: + if not self._internal_email(receiver_email): + print( + "Sorry, we cannot send an email to a person outside of the organization" + ) + return False + print( + f"Sent an email from {self.sender_email} to {receiver_email} with subject '{subject}' and content:\n{content}" + ) + return True + + def _internal_email(self, email: str) -> bool: + return email.endswith("@mycompany.com") + + +class EmailStats: + def __init__(self): + self.success = 0 + self.fail = 0 + + def update(self, result: bool): + if result: + self.success += 1 + else: + self.fail += 1 + + +class PrepareEmail(Event): + receiver: str + subject: str + content: str + + +class SendEmail(Event): + success: bool + + +class EmailFlowState(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + email_num: int = 0 + email_client: Optional[EmailClient] = None + + +async def get_llm(*args, **kwargs) -> OpenAI: + return OpenAI("gpt-4.1") + + +async def get_email_stats(*args, **kwargs) -> EmailStats: + return EmailStats() + + +class EmailFlow(Workflow): + @step + async def prepare_email( + self, + ev: StartEvent, + ctx: Context[EmailFlowState], + llm: Annotated[OpenAI, Resource(get_llm)], + ) -> PrepareEmail | StopEvent | None: + async with ctx.store.edit_state() as state: + cl = EmailClient(sender_email=ev.sender) + if hasattr(cl, "sender_email"): + state.email_client = cl + state.email_num = len(ev.receivers) + else: + return StopEvent( + result="It is not possible to send emails from your current address: please use a mycompany.com address and try again." + ) + email_content = await llm.acomplete( + f"Given this email draft: {ev.draft} and subject: {ev.subject}, can you please create an fully-formed email message to send?" + ) + for receiver in ev.receivers: + ctx.send_event( + PrepareEmail( + receiver=receiver, subject=ev.subject, content=email_content.text + ) + ) + + @step + async def send_email( + self, + ev: PrepareEmail, + ctx: Context[EmailFlowState], + stats: Annotated[EmailStats, Resource(get_email_stats)], + ) -> SendEmail: + state = await ctx.store.get_state() + succ = state.email_client.send(ev.receiver, ev.subject, ev.content) # type: ignore + stats.update(succ) + return SendEmail(success=succ) + + @step + async def collect_email_stats( + self, + ev: SendEmail, + ctx: Context[EmailFlowState], + stats: Annotated[EmailStats, Resource(get_email_stats)], + ) -> StopEvent | None: + state = await ctx.store.get_state() + evs = ctx.collect_events(ev, [SendEmail] * state.email_num) + if evs: + return StopEvent( + result=f"Sent {stats.success} emails, failed to send {stats.fail} emails" + ) + + +async def main(sender: str, receivers: list[str], subject: str, draft: str) -> None: + w = EmailFlow(timeout=60, verbose=False) + result = await w.run( + sender=sender, receivers=receivers, subject=subject, draft=draft + ) + print(str(result)) + + +workflow = EmailFlow(timeout=None) + +if __name__ == "__main__": + import asyncio + import os + from argparse import ArgumentParser + + parser = ArgumentParser() + parser.add_argument( + "-s", + "--sender", + required=True, + help="Sender email (must end with @mycompany.com)", + ) + parser.add_argument( + "-r", + "--receiver", + required=True, + action="append", + help="Email for the receiver (must end with @mycompany.com). Can be repeated", + ) + parser.add_argument("-t", "--subject", required=True, help="Subject of the email") + parser.add_argument("-d", "--draft", required=True, help="Draft for the email") + + args = parser.parse_args() + + if not os.getenv("OPENAI_API_KEY", None): + raise ValueError( + "You need to set OPENAI_API_KEY in your environment before using this workflow" + ) + + asyncio.run( + main( + sender=args.sender, + receivers=args.receiver, + subject=args.subject, + draft=args.draft, + ) + ) diff --git a/{{ _copier_conf.answers_file }}.jinja b/{{ _copier_conf.answers_file }}.jinja new file mode 100644 index 0000000..88acac8 --- /dev/null +++ b/{{ _copier_conf.answers_file }}.jinja @@ -0,0 +1,2 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +{{ _copier_answers|to_nice_yaml -}} \ No newline at end of file