diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..10192fb7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: + - main + paths-ignore: + - pyproject.toml + pull_request: + branches: + - main + +jobs: + commit-lint: + if: ${{ github.event_name == 'pull_request' }} + uses: ./.github/workflows/commitlint.yml + + lint: + uses: ./.github/workflows/lint.yml + + test: + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 00000000..8562e455 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,47 @@ +name: Commit Lint + +on: + workflow_call + +jobs: + commitlint: + name: Commit Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 22 + + - name: Install Git + run: | + if ! command -v git &> /dev/null; then + echo "Git is not installed. Installing..." + sudo apt-get update + sudo apt-get install -y git + else + echo "Git is already installed." + fi + + - name: Install commitlint + run: | + npm install conventional-changelog-conventionalcommits + npm install commitlint@latest + npm install @commitlint/config-conventional + + - name: Configure + run: | + echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js + + - name: Validate PR commits with commitlint + run: | + git fetch origin pull/${{ github.event.pull_request.number }}/head:pr_branch + npx commitlint --from ${{ github.event.pull_request.base.sha }} --to pr_branch --verbose diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..c37daeba --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: Lint + +on: + workflow_call + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check static types + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 00000000..ead50782 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,28 @@ +name: Publish Docs + +on: + push: + branches: + - main + paths: + - "docs/**" + - "pyproject.toml" + +jobs: + publish-docs: + runs-on: ubuntu-latest + if: ${{ github.repository == 'UiPath/uipath-llamaindex-python' }} + steps: + - name: Trigger Publish Docs + run: | + repo_owner="uipath" + repo_name="uipath-python" + event_type="publish-docs" + + curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.REPO_ACCESS }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/$repo_owner/$repo_name/dispatches \ + -d "{\"event_type\": \"$event_type\", \"client_payload\": {}}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 247c3209..b479c5db 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,10 +9,19 @@ on: - pyproject.toml jobs: + lint: + uses: ./.github/workflows/lint.yml + + test: + uses: ./.github/workflows/test.yml + build: name: Build runs-on: ubuntu-latest + needs: + - lint + - test if: ${{ github.repository == 'UiPath/uipath-llamaindex-python' }} permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..e871daf4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + workflow_call: + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest + + continue-on-error: true + diff --git a/pyproject.toml b/pyproject.toml index 12205150..8e9d1d29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,9 @@ line-ending = "auto" plugins = [ "pydantic.mypy" ] +exclude = [ + "samples/.*" +] follow_imports = "silent" warn_redundant_casts = true diff --git a/src/uipath_llamaindex/_cli/_runtime/_hitl.py b/src/uipath_llamaindex/_cli/_runtime/_hitl.py index 04a88075..05dfe0c8 100644 --- a/src/uipath_llamaindex/_cli/_runtime/_hitl.py +++ b/src/uipath_llamaindex/_cli/_runtime/_hitl.py @@ -90,7 +90,7 @@ async def read(cls, resume_trigger: UiPathResumeTrigger) -> Optional[str]: return job.output_arguments case UiPathResumeTriggerType.API: - if resume_trigger.api_resume.inbox_id: + if resume_trigger.api_resume and resume_trigger.api_resume.inbox_id: return await _get_api_payload(resume_trigger.api_resume.inbox_id) case _: diff --git a/src/uipath_llamaindex/_cli/_runtime/_runtime.py b/src/uipath_llamaindex/_cli/_runtime/_runtime.py index 42a81763..e423b079 100644 --- a/src/uipath_llamaindex/_cli/_runtime/_runtime.py +++ b/src/uipath_llamaindex/_cli/_runtime/_runtime.py @@ -12,7 +12,10 @@ JsonPickleSerializer, ) from llama_index.core.workflow.handler import WorkflowHandler -from openinference.instrumentation.llama_index import LlamaIndexInstrumentor, get_current_span +from openinference.instrumentation.llama_index import ( + LlamaIndexInstrumentor, + get_current_span, +) from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor @@ -23,14 +26,13 @@ UiPathRuntimeResult, UiPathRuntimeStatus, ) +from uipath.tracing import TracingManager from .._tracing._oteladapter import LlamaIndexExporter from ._context import UiPathLlamaIndexRuntimeContext from ._exception import UiPathLlamaIndexRuntimeError from ._hitl import HitlProcessor, HitlReader -from uipath.tracing import TracingManager - logger = logging.getLogger(__name__) @@ -74,14 +76,20 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: if os.path.exists(self.state_file_path): os.remove(self.state_file_path) + if self.context.workflow is None: + return None + start_event_class = self.context.workflow._start_event_class - ev = start_event_class(**self.context.input_json) + ev = start_event_class(**(self.context.input_json or {})) await self.load_workflow_context() + if self.context.workflow_context is None: + return None + handler: WorkflowHandler = self.context.workflow.run( start_event=ev if self.context.resume else None, ctx=self.context.workflow_context, - **self.context.input_json, + **(self.context.input_json or {}), ) resume_trigger: Optional[UiPathResumeTrigger] = None @@ -94,9 +102,10 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: if self.context.resume and not response_applied: # If we are resuming, we need to apply the response to the event stream. response_applied = True - self.context.workflow_context.send_event( - await self.get_response_event() - ) + response_event = await self.get_response_event() + if response_event: + # If we have a response event, send it to the workflow context. + self.context.workflow_context.send_event(response_event) else: resume_trigger = await hitl_processor.create_resume_trigger() break @@ -244,6 +253,9 @@ async def load_workflow_context(self): """ logger.debug(f"Resumed: {self.context.resume} Input: {self.context.input_json}") + if self.context.workflow is None: + return + if not self.context.resume: self.context.workflow_context = Context(self.context.workflow) return @@ -277,7 +289,7 @@ async def get_response_event(self) -> Optional[HumanResponseEvent]: """ if self.context.input_json: # If input_json is provided, use it to create a HumanResponseEvent - return HumanResponseEvent(**self.context.input_json) + return HumanResponseEvent(**(self.context.input_json or {})) # If resumed_trigger is set, fetch the feedback if self.context.resumed_trigger: feedback = await HitlReader.read(self.context.resumed_trigger) diff --git a/src/uipath_llamaindex/_cli/cli_init.py b/src/uipath_llamaindex/_cli/cli_init.py index e0124973..b74df43b 100644 --- a/src/uipath_llamaindex/_cli/cli_init.py +++ b/src/uipath_llamaindex/_cli/cli_init.py @@ -5,7 +5,7 @@ from typing import Any, Dict from llama_index.core.workflow import StopEvent, Workflow -from llama_index.core.workflow.drawing import StepConfig +from llama_index.core.workflow.drawing import StepConfig # type: ignore from llama_index.core.workflow.events import ( HumanResponseEvent, InputRequiredEvent, @@ -129,11 +129,13 @@ def draw_all_possible_flows_mermaid( # Track event types to avoid duplicates event_types = {} + current_stop_event = ( + None # Only one kind of `StopEvent` is allowed in a `Workflow`. + ) + step_config: StepConfig | None = None - # Only one kind of `StopEvent` is allowed in a `Workflow`. - current_stop_event = None for _, step_func in steps.items(): - step_config: StepConfig = getattr(step_func, "__step_config", None) + step_config = getattr(step_func, "__step_config", None) if step_config is None: continue @@ -227,12 +229,13 @@ def draw_all_possible_flows_mermaid( event_id = f"event_{clean_id(event_name)}" if step_name == "_done" and issubclass(event_type, StopEvent): - stop_event_name = current_stop_event.__name__ - stop_event_id = f"event_{clean_id(stop_event_name)}" - edge = f"{stop_event_id} --> {step_id}" - if edge not in edges: - edges.add(edge) - mermaid_diagram.append(f" {edge}") + if current_stop_event: + stop_event_name = current_stop_event.__name__ + stop_event_id = f"event_{clean_id(stop_event_name)}" + edge = f"{stop_event_id} --> {step_id}" + if edge not in edges: + edges.add(edge) + mermaid_diagram.append(f" {edge}") else: edge = f"{event_id} --> {step_id}" if edge not in edges: diff --git a/src/uipath_llamaindex/_cli/cli_run.py b/src/uipath_llamaindex/_cli/cli_run.py index ab97bbf1..a17b95fc 100644 --- a/src/uipath_llamaindex/_cli/cli_run.py +++ b/src/uipath_llamaindex/_cli/cli_run.py @@ -1,7 +1,7 @@ import asyncio import logging from os import environ as env -from typing import Any, Dict, Optional +from typing import Optional from dotenv import load_dotenv from uipath._cli._runtime._contracts import UiPathTraceContext diff --git a/src/uipath_llamaindex/embeddings/_openai_embeddings.py b/src/uipath_llamaindex/embeddings/_openai_embeddings.py index 475cb8fb..ae129fe4 100644 --- a/src/uipath_llamaindex/embeddings/_openai_embeddings.py +++ b/src/uipath_llamaindex/embeddings/_openai_embeddings.py @@ -1,48 +1,46 @@ -import os -from enum import Enum -from typing import Any, Union - -from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding - - -class OpenAIEmbeddingModel(Enum): - TEXT_EMBEDDING_3_LARGE = "text-embedding-3-large" - TEXT_EMBEDDING_ADA_002 = "text-embedding-ada-002" - - -class UiPathOpenAIEmbedding(AzureOpenAIEmbedding): - def __init__( - self, - model: Union[ - str, OpenAIEmbeddingModel - ] = OpenAIEmbeddingModel.TEXT_EMBEDDING_ADA_002, - api_version: str = "2024-10-21", - **kwargs: Any, - ): - default_headers_dict = { - "X-UIPATH-STREAMING-ENABLED": "false", - "X-UiPath-LlmGateway-RequestingProduct": "uipath-python-sdk", - "X-UiPath-LlmGateway-RequestingFeature": "llama-index-agent", - } - - model_value = model.value if isinstance(model, OpenAIEmbeddingModel) else model - - base_url = os.environ.get( - "UIPATH_URL", "EMPTY" - ).rstrip("/") - - if base_url == "EMPTY": - raise ValueError( - "UIPATH_URL environment variable is not set. Please run uipath auth." - ) - - defaults = { - "model": model_value, - "deployment_name": model_value, - "azure_endpoint": f"{base_url}/llmgateway_/", - "api_key": os.environ.get("UIPATH_ACCESS_TOKEN"), - "api_version": api_version, - "default_headers": default_headers_dict, - } - final_kwargs = {**defaults, **kwargs} - super().__init__(**final_kwargs) +import os +from enum import Enum +from typing import Any, Union + +from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding # type: ignore + + +class OpenAIEmbeddingModel(Enum): + TEXT_EMBEDDING_3_LARGE = "text-embedding-3-large" + TEXT_EMBEDDING_ADA_002 = "text-embedding-ada-002" + + +class UiPathOpenAIEmbedding(AzureOpenAIEmbedding): + def __init__( + self, + model: Union[ + str, OpenAIEmbeddingModel + ] = OpenAIEmbeddingModel.TEXT_EMBEDDING_ADA_002, + api_version: str = "2024-10-21", + **kwargs: Any, + ): + default_headers_dict = { + "X-UIPATH-STREAMING-ENABLED": "false", + "X-UiPath-LlmGateway-RequestingProduct": "uipath-python-sdk", + "X-UiPath-LlmGateway-RequestingFeature": "llama-index-agent", + } + + model_value = model.value if isinstance(model, OpenAIEmbeddingModel) else model + + base_url = os.environ.get("UIPATH_URL", "EMPTY").rstrip("/") + + if base_url == "EMPTY": + raise ValueError( + "UIPATH_URL environment variable is not set. Please run uipath auth." + ) + + defaults = { + "model": model_value, + "deployment_name": model_value, + "azure_endpoint": f"{base_url}/llmgateway_/", + "api_key": os.environ.get("UIPATH_ACCESS_TOKEN"), + "api_version": api_version, + "default_headers": default_headers_dict, + } + final_kwargs = {**defaults, **kwargs} + super().__init__(**final_kwargs) diff --git a/src/uipath_llamaindex/llms/_openai_llms.py b/src/uipath_llamaindex/llms/_openai_llms.py index 15ccbf87..85d7e83b 100644 --- a/src/uipath_llamaindex/llms/_openai_llms.py +++ b/src/uipath_llamaindex/llms/_openai_llms.py @@ -1,54 +1,52 @@ -import os -from enum import Enum -from typing import Any, Union - -from llama_index.llms.azure_openai import AzureOpenAI - - -class OpenAIModel(Enum): - GPT_4_1_2025_04_14 = "gpt-4.1-2025-04-14" - GPT_4_1_MINI_2025_04_14 = "gpt-4.1-mini-2025-04-14" - GPT_4_1_NANO_2025_04_14 = "gpt-4.1-nano-2025-04-14" - GPT_4O_2024_05_13 = "gpt-4o-2024-05-13" - GPT_4O_2024_08_06 = "gpt-4o-2024-08-06" - GPT_4O_2024_11_20 = "gpt-4o-2024-11-20" - GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18" - O3_MINI_2025_01_31 = "o3-mini-2025-01-31" - TEXT_DAVINCI_003 = "text-davinci-003" - - -# Define your custom AzureOpenAI class with default settings -class UiPathOpenAI(AzureOpenAI): - def __init__( - self, - model: Union[str, OpenAIModel] = OpenAIModel.GPT_4O_MINI_2024_07_18, - api_version: str = "2024-10-21", - **kwargs: Any, - ): - default_headers_dict = { - "X-UIPATH-STREAMING-ENABLED": "false", - "X-UiPath-LlmGateway-RequestingProduct": "uipath-python-sdk", - "X-UiPath-LlmGateway-RequestingFeature": "llama-index-agent", - } - model_value = model.value if isinstance(model, OpenAIModel) else model - - base_url = os.environ.get( - "UIPATH_URL", "EMPTY" - ).rstrip("/") - - if base_url == "EMPTY": - raise ValueError( - "UIPATH_URL environment variable is not set. Please run uipath auth." - ) - - defaults = { - "model": model_value, - "deployment_name": model_value, - "azure_endpoint": f"{base_url}/llmgateway_/", - "api_key": os.environ.get("UIPATH_ACCESS_TOKEN"), - "api_version": api_version, - "is_chat_model": True, - "default_headers": default_headers_dict, - } - final_kwargs = {**defaults, **kwargs} - super().__init__(**final_kwargs) \ No newline at end of file +import os +from enum import Enum +from typing import Any, Union + +from llama_index.llms.azure_openai import AzureOpenAI # type: ignore + + +class OpenAIModel(Enum): + GPT_4_1_2025_04_14 = "gpt-4.1-2025-04-14" + GPT_4_1_MINI_2025_04_14 = "gpt-4.1-mini-2025-04-14" + GPT_4_1_NANO_2025_04_14 = "gpt-4.1-nano-2025-04-14" + GPT_4O_2024_05_13 = "gpt-4o-2024-05-13" + GPT_4O_2024_08_06 = "gpt-4o-2024-08-06" + GPT_4O_2024_11_20 = "gpt-4o-2024-11-20" + GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18" + O3_MINI_2025_01_31 = "o3-mini-2025-01-31" + TEXT_DAVINCI_003 = "text-davinci-003" + + +# Define your custom AzureOpenAI class with default settings +class UiPathOpenAI(AzureOpenAI): + def __init__( + self, + model: Union[str, OpenAIModel] = OpenAIModel.GPT_4O_MINI_2024_07_18, + api_version: str = "2024-10-21", + **kwargs: Any, + ): + default_headers_dict = { + "X-UIPATH-STREAMING-ENABLED": "false", + "X-UiPath-LlmGateway-RequestingProduct": "uipath-python-sdk", + "X-UiPath-LlmGateway-RequestingFeature": "llama-index-agent", + } + model_value = model.value if isinstance(model, OpenAIModel) else model + + base_url = os.environ.get("UIPATH_URL", "EMPTY").rstrip("/") + + if base_url == "EMPTY": + raise ValueError( + "UIPATH_URL environment variable is not set. Please run uipath auth." + ) + + defaults = { + "model": model_value, + "deployment_name": model_value, + "azure_endpoint": f"{base_url}/llmgateway_/", + "api_key": os.environ.get("UIPATH_ACCESS_TOKEN"), + "api_version": api_version, + "is_chat_model": True, + "default_headers": default_headers_dict, + } + final_kwargs = {**defaults, **kwargs} + super().__init__(**final_kwargs)