diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3212c7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +name: CI + +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] + release: + types: [published] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv sync --dev + - name: Ruff + run: uv run ruff check . + - name: Black + run: uv run black --check . + - name: mypy + run: uv run mypy intent_kit tests + + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv sync --dev + - name: Tests with coverage + run: | + uv run pytest --cov=intent_kit --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + eval: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv sync --dev + - name: Evaluations (Mock Mode) + run: uv run python -m intent_kit.evals.run_all_evals --quiet --mock + + build: + runs-on: ubuntu-latest + needs: [test, eval] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install build dependencies + run: | + pip install build + - name: Build package + run: python -m build --sdist --wheel --outdir dist + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-distributions + path: dist + + docs: + runs-on: ubuntu-latest + needs: [test, eval] + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv sync --dev + - name: Build documentation + run: uv run mkdocs build + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: intentkit-docs + directory: site + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + + publish: + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install build and publish dependencies + run: | + pip install build twine + - name: Build package + run: python -m build --sdist --wheel --outdir dist + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/test-and-eval.yml b/.github/workflows/test-and-eval.yml deleted file mode 100644 index ee80e03..0000000 --- a/.github/workflows/test-and-eval.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Tests and Evaluations - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test-and-eval: - runs-on: ubuntu-latest - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - name: Install uv - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - - name: Install dependencies - run: | - uv sync --dev - - name: Tests - run: uv run pytest -q - - name: Evaluations (Mock Mode) - run: uv run python -m intent_kit.evals.run_all_evals --quiet --mock \ No newline at end of file diff --git a/.gitignore b/.gitignore index ac509ed..1e7d110 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ htmlcov/ .tox/ reports/ +# Documentation +site/ + # Evaluation Results intent_kit/evals/results/ intent_kit/evals/reports/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..30f83c6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.7 + hooks: + - id: ruff + args: [--fix] + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1246a1f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2025-07-07 + +### Added +- Experimental CI/CD pipeline with linting, type checking, test coverage reporting via Codecov, and trusted publishing. +- MkDocs documentation site using the Material theme with live search and API reference. +- Binder environment for interactive notebooks and examples. +- Pre-commit configuration powered by Ruff, Black, and mypy. +- Documentation badges for CI, coverage, docs, and PyPI. + +### Changed +- Updated development dependencies and tooling for modern Python 3.11 ecosystem. + +### Removed +- N/A \ No newline at end of file diff --git a/README.md b/README.md index a8841c9..88e16a5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # intent-kit + + +[![CI](https://github.com/Stephen-Collins-tech/intent-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/Stephen-Collins-tech/intent-kit/actions/workflows/ci.yml) +[![Coverage Status](https://codecov.io/gh/Stephen-Collins-tech/intent-kit/branch/main/graph/badge.svg)](https://codecov.io/gh/Stephen-Collins-tech/intent-kit) +[![Documentation](https://img.shields.io/badge/docs-online-blue)](https://docs.intentkit.io) +[![PyPI](https://img.shields.io/pypi/v/intent-kit)](https://pypi.org/project/intent-kit) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/Stephen-Collins-tech/intent-kit/HEAD?filepath=examples%2Fsimple_demo.ipynb) + A Python library for building hierarchical intent classification and execution systems with support for multiple AI service backends. ## Features diff --git a/binder/requirements.txt b/binder/requirements.txt new file mode 100644 index 0000000..73072ec --- /dev/null +++ b/binder/requirements.txt @@ -0,0 +1,3 @@ +-e . +jupyterlab +ipykernel \ No newline at end of file diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 0000000..e680210 --- /dev/null +++ b/docs/api_reference.md @@ -0,0 +1,3 @@ +# API Reference + +::: intent_kit \ No newline at end of file diff --git a/docs/examples/calculator-bot.md b/docs/examples/calculator-bot.md new file mode 100644 index 0000000..a90a220 --- /dev/null +++ b/docs/examples/calculator-bot.md @@ -0,0 +1,36 @@ +# Calculator Bot Example + +This example shows how to build a simple calculator bot that can add and subtract numbers using intent-kit. + +```python +from intent_kit import IntentGraphBuilder, handler + +def add(a: int, b: int) -> str: + return str(a + b) + +def subtract(a: int, b: int) -> str: + return str(a - b) + +add_handler = handler( + name="add", + description="Add two numbers", + handler_func=add, + param_schema={"a": int, "b": int}, +) + +subtract_handler = handler( + name="subtract", + description="Subtract two numbers", + handler_func=subtract, + param_schema={"a": int, "b": int}, +) + +graph = ( + IntentGraphBuilder() + .root(add_handler) + .root(subtract_handler) + .build() +) + +print(graph.route("add 2 3").output) # -> 5 +``` \ No newline at end of file diff --git a/docs/examples/context-aware-chatbot.md b/docs/examples/context-aware-chatbot.md new file mode 100644 index 0000000..bc6c9e9 --- /dev/null +++ b/docs/examples/context-aware-chatbot.md @@ -0,0 +1,39 @@ +# Context-Aware Chatbot Example + +This example is adapted from `examples/context_demo.py`. It demonstrates how `IntentContext` can persist conversation state across multiple turns. + +```python +from intent_kit import IntentGraphBuilder, handler +from intent_kit.context import IntentContext + +# Handler remembers how many times we greeted the user + +def greet(name: str, context: IntentContext) -> str: + count = context.get("greet_count", 0) + 1 + context.set("greet_count", count, modified_by="greet") + return f"Hello {name}! (greeting #{count})" + +hello_handler = handler( + name="greet", + description="Greet the user and track greeting count", + handler_func=greet, + param_schema={"name": str}, +) + +graph = IntentGraphBuilder().root(hello_handler).build() + +ctx = IntentContext(session_id="abc123") +print(graph.route("hello alice", context=ctx).output) +print(graph.route("hello bob", context=ctx).output) # Greeting count increments +``` + +Running the above prints: + +``` +Hello alice! (greeting #1) +Hello bob! (greeting #2) +``` + +Key take-aways: +* `IntentContext` persists between calls so you can build multi-turn experiences. +* Each handler can declare which context keys it reads/writes for explicit dependency tracking. \ No newline at end of file diff --git a/docs/examples/multi-intent-routing.md b/docs/examples/multi-intent-routing.md new file mode 100644 index 0000000..7f04226 --- /dev/null +++ b/docs/examples/multi-intent-routing.md @@ -0,0 +1,51 @@ +# Multi-Intent Routing Example + +The following shows how intent-kit can handle _multiple_ intents in a single user utterance using a splitter node. + +```python +from intent_kit import IntentGraphBuilder, handler, rule_splitter_node + +# Handlers for individual intents + +def greet(name: str) -> str: + return f"Hello {name}!" + +def weather(city: str) -> str: + return f"The weather in {city} is sunny." + +hello = handler( + name="greet", + description="Greet the user", + handler_func=greet, + param_schema={"name": str}, +) + +weather_h = handler( + name="weather", + description="Get weather information", + handler_func=weather, + param_schema={"city": str}, +) + +# Splitter routes parts of the sentence to different handlers +splitter = rule_splitter_node( + name="multi_split", + children=[hello, weather_h], +) + +graph = IntentGraphBuilder().root(splitter).build() + +result = graph.route("Hello Alice and what's the weather in Paris?") +print(result.output) +``` + +The `rule_splitter_node` looks for keywords ("hello", "weather", etc.) and breaks the user input into sub-phrases routed to the appropriate handlers. The final `result.output` aggregates the outputs from each intent, e.g.: + +``` +{ + "greet": "Hello Alice!", + "weather": "The weather in Paris is sunny." +} +``` + +For a more robust version that uses LLM-based splitting, see `examples/multi_intent_demo.py`. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..46b25fd --- /dev/null +++ b/docs/index.md @@ -0,0 +1,5 @@ +# Welcome to intent-kit + +intent-kit is a universal Python framework for building hierarchical intent graphs with deterministic, fully-auditable execution models. + +Explore the Quickstart to get running in minutes, or dive into the full API reference. \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..03640a4 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,25 @@ +# Quickstart + +Install intent-kit: + +```bash +pip install intent-kit # Or 'intent-kit[openai]' for LLM support +``` + +Create and run a simple graph: + +```python +from intent_kit import IntentGraphBuilder, handler + +hello_handler = handler( + name="greet", + description="Greet the user", + handler_func=lambda name: f"Hello {name}!", + param_schema={"name": str}, +) + +graph = IntentGraphBuilder().root(hello_handler).build() +print(graph.route("hello alice").output) +``` + +For more in-depth examples, see the Examples section. \ No newline at end of file diff --git a/examples/advanced_remediation_demo.py b/examples/advanced_remediation_demo.py index 408fd32..2d51ee7 100644 --- a/examples/advanced_remediation_demo.py +++ b/examples/advanced_remediation_demo.py @@ -30,10 +30,16 @@ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") or "sk-mock-openai" GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") or "sk-mock-gemini" -LLM_CONFIG_1 = {"provider": "openai", - "model": "gpt-4.1-mini", "api_key": OPENAI_API_KEY} -LLM_CONFIG_2 = {"provider": "google", - "model": "gemini-2.5-flash", "api_key": GOOGLE_API_KEY} +LLM_CONFIG_1 = { + "provider": "openai", + "model": "gpt-4.1-mini", + "api_key": OPENAI_API_KEY, +} +LLM_CONFIG_2 = { + "provider": "google", + "model": "gemini-2.5-flash", + "api_key": GOOGLE_API_KEY, +} # --- Core Handler: Simulates model confusion and ambiguity --- @@ -68,24 +74,27 @@ def analyze_sentiment(review_text: str, context: IntentContext) -> str: description="Uses self-reflection if it fails on ambiguous reviews.", handler_func=analyze_sentiment, param_schema={"review_text": str}, - remediation_strategies=[create_self_reflect_strategy( - LLM_CONFIG_1, max_reflections=1)], + remediation_strategies=[ + create_self_reflect_strategy(LLM_CONFIG_1, max_reflections=1) + ], ), handler( name="consensus_vote_sentiment", description="Uses consensus voting between two LLMs on conflicting reviews.", handler_func=analyze_sentiment, param_schema={"review_text": str}, - remediation_strategies=[create_consensus_vote_strategy( - [LLM_CONFIG_1, LLM_CONFIG_2], vote_threshold=0.5)], + remediation_strategies=[ + create_consensus_vote_strategy( + [LLM_CONFIG_1, LLM_CONFIG_2], vote_threshold=0.5 + ) + ], ), handler( name="alternate_prompt_sentiment", description="Retries with alternate prompt if ambiguous input causes a failure.", handler_func=analyze_sentiment, param_schema={"review_text": str}, - remediation_strategies=[ - create_alternate_prompt_strategy(LLM_CONFIG_1)], + remediation_strategies=[create_alternate_prompt_strategy(LLM_CONFIG_1)], ), ] @@ -94,22 +103,24 @@ def main(): context = IntentContext() print("=== Advanced Remediation Strategies Demo ===\n") - print("This demo shows how self-reflection, consensus voting, and alternate prompts can recover\n" - "from ambiguous or conflicting results in real-world sentiment analysis tasks.\n") + print( + "This demo shows how self-reflection, consensus voting, and alternate prompts can recover\n" + "from ambiguous or conflicting results in real-world sentiment analysis tasks.\n" + ) # Each case is designed to *require* remediation. test_cases = [ ( "This product is not bad at all, actually quite good!", - "Triggers self-reflection: Model may misinterpret 'not bad'." + "Triggers self-reflection: Model may misinterpret 'not bad'.", ), ( "I hate the design but love the features, not my favorite but not bad.", - "Triggers consensus voting: LLMs likely to disagree on mixed sentiment." + "Triggers consensus voting: LLMs likely to disagree on mixed sentiment.", ), ( "Okay, fine, whatever, not bad I guess, meh", - "Triggers alternate prompt: All terms are vague, likely to fail first try." + "Triggers alternate prompt: All terms are vague, likely to fail first try.", ), ] @@ -120,8 +131,7 @@ def main(): print(f"Case: {case_desc}") try: - result: ExecutionResult = h.execute( - user_input=review_text, context=context) + result: ExecutionResult = h.execute(user_input=review_text, context=context) print(f"Success: {result.success}") print(f"Output: {result.output}") if result.error: @@ -135,7 +145,9 @@ def main(): print("• Alternate prompt: Handler retries with a new prompt if it can't answer.") if "mock" in OPENAI_API_KEY or "mock" in GOOGLE_API_KEY: - print("\nšŸ’” Pro Tip: For real LLM behavior, add your OpenAI and Gemini API keys to a .env file.") + print( + "\nšŸ’” Pro Tip: For real LLM behavior, add your OpenAI and Gemini API keys to a .env file." + ) if __name__ == "__main__": diff --git a/examples/classifier_remediation_demo.py b/examples/classifier_remediation_demo.py index a89ae50..162a21d 100644 --- a/examples/classifier_remediation_demo.py +++ b/examples/classifier_remediation_demo.py @@ -15,15 +15,15 @@ from intent_kit.node.types import ExecutionResult, ExecutionError from intent_kit.handlers.remediation import ( RemediationStrategy, - create_keyword_fallback_strategy, - register_remediation_strategy + register_remediation_strategy, ) from intent_kit.context import IntentContext -from intent_kit.builder import handler, llm_classifier, IntentGraphBuilder +from intent_kit.builder import handler, IntentGraphBuilder import sys import os from typing import Optional, Callable, List from dotenv import load_dotenv + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Load environment variables @@ -37,7 +37,7 @@ LLM_CONFIG = { "provider": "openai", "model": "gpt-4.1-mini", - "api_key": os.getenv("OPENAI_API_KEY") + "api_key": os.getenv("OPENAI_API_KEY"), } @@ -48,7 +48,9 @@ def greet_handler(name: str, context: IntentContext) -> str: return f"Hello {name}! (Greeting #{greeting_count})" -def calculate_handler(operation: str, a: float, b: float, context: IntentContext) -> str: +def calculate_handler( + operation: str, a: float, b: float, context: IntentContext +) -> str: """Simple calculation handler.""" ops = {"add": "+", "plus": "+", "multiply": "*", "times": "*"} op = ops.get(operation.lower(), operation) @@ -78,10 +80,11 @@ def help_handler(context: IntentContext) -> str: def create_failing_classifier(): """Create a classifier that deliberately fails to trigger remediation.""" - def failing_classifier(user_input: str, children: List, context: Optional[dict] = None): + def failing_classifier( + user_input: str, children: List, context: Optional[dict] = None + ): """A classifier that always fails to demonstrate remediation.""" - logger.warning( - "FailingClassifier: Deliberately failing to trigger remediation") + logger.warning("FailingClassifier: Deliberately failing to trigger remediation") return None # Always return None to trigger remediation return failing_classifier @@ -99,15 +102,17 @@ def execute( original_error: Optional[ExecutionError] = None, classifier_func: Optional[Callable] = None, available_children: Optional[List] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Use a simple rule-based classifier as fallback.""" self.logger.info( - f"CustomClassifierFallbackStrategy: Using rule-based classification for {node_name}") + f"CustomClassifierFallbackStrategy: Using rule-based classification for {node_name}" + ) if not available_children: self.logger.warning( - f"CustomClassifierFallbackStrategy: No available children for {node_name}") + f"CustomClassifierFallbackStrategy: No available children for {node_name}" + ) return None # Simple rule-based classification @@ -116,10 +121,20 @@ def execute( # Define simple rules rules = [ (["hello", "hi", "hey", "greet"], "greet"), - (["calculate", "compute", "math", "add", - "multiply", "plus", "times"], "calculate"), + ( + [ + "calculate", + "compute", + "math", + "add", + "multiply", + "plus", + "times", + ], + "calculate", + ), (["weather", "temperature", "forecast"], "weather"), - (["help", "assist", "support"], "help") + (["help", "assist", "support"], "help"), ] # Find matching rule @@ -130,13 +145,14 @@ def execute( for child in available_children: if child.name.lower() == intent_name: self.logger.info( - f"CustomClassifierFallbackStrategy: Matched '{child.name}' using keyword '{keyword}'") + f"CustomClassifierFallbackStrategy: Matched '{child.name}' using keyword '{keyword}'" + ) # Execute the chosen child - child_result = child.execute( - user_input, context) + child_result = child.execute(user_input, context) from intent_kit.node.enums import NodeType + return ExecutionResult( success=True, node_name=node_name, @@ -147,18 +163,23 @@ def execute( error=None, params={ "chosen_child": child.name, - "available_children": [c.name for c in available_children], + "available_children": [ + c.name for c in available_children + ], "remediation_strategy": self.name, - "matched_keyword": keyword + "matched_keyword": keyword, }, - children_results=[child_result] + children_results=[child_result], ) self.logger.warning( - f"CustomClassifierFallbackStrategy: No rule match found for {node_name}") + f"CustomClassifierFallbackStrategy: No rule match found for {node_name}" + ) return None - return CustomClassifierFallbackStrategy("custom_classifier_fallback", "Custom rule-based classifier fallback") + return CustomClassifierFallbackStrategy( + "custom_classifier_fallback", "Custom rule-based classifier fallback" + ) def create_intent_graph(): @@ -167,7 +188,8 @@ def create_intent_graph(): # Create custom classifier fallback strategy custom_classifier_strategy = create_custom_classifier_fallback() register_remediation_strategy( - "custom_classifier_fallback", custom_classifier_strategy) + "custom_classifier_fallback", custom_classifier_strategy + ) # Create handlers handlers = [ @@ -178,7 +200,7 @@ def create_intent_graph(): param_schema={"name": str}, llm_config=LLM_CONFIG, context_inputs={"greeting_count"}, - context_outputs={"greeting_count"} + context_outputs={"greeting_count"}, ), handler( name="calculate", @@ -187,7 +209,7 @@ def create_intent_graph(): param_schema={"operation": str, "a": float, "b": float}, llm_config=LLM_CONFIG, context_inputs={"calc_history"}, - context_outputs={"calc_history"} + context_outputs={"calc_history"}, ), handler( name="weather", @@ -196,7 +218,7 @@ def create_intent_graph(): param_schema={"location": str}, llm_config=LLM_CONFIG, context_inputs={"weather_count"}, - context_outputs={"weather_count"} + context_outputs={"weather_count"}, ), handler( name="help", @@ -205,8 +227,8 @@ def create_intent_graph(): param_schema={}, llm_config=LLM_CONFIG, context_inputs={"help_count"}, - context_outputs={"help_count"} - ) + context_outputs={"help_count"}, + ), ] # Create classifier with a failing classifier to force remediation @@ -221,8 +243,7 @@ def create_intent_graph(): children=handlers, description="Main intent classifier with remediation", # Try keyword first, then custom - remediation_strategies=[ - "keyword_fallback", "custom_classifier_fallback"] + remediation_strategies=["keyword_fallback", "custom_classifier_fallback"], ) # Build and return the graph @@ -233,7 +254,9 @@ def run_demo(): """Run the classifier remediation demo.""" print("šŸ”„ Phase 2: Classifier Remediation System Demo") print("=" * 55) - print("This demo uses a deliberately failing classifier to showcase remediation strategies.") + print( + "This demo uses a deliberately failing classifier to showcase remediation strategies." + ) print() # Create intent graph @@ -270,19 +293,20 @@ def run_demo(): print(f"āœ… Success: {result.output}") if result.params and "remediation_strategy" in result.params: print( - f"šŸ”„ Remediation used: {result.params['remediation_strategy']}") + f"šŸ”„ Remediation used: {result.params['remediation_strategy']}" + ) if "confidence_score" in result.params: print( - f"šŸ“Š Confidence score: {result.params['confidence_score']:.2f}") + f"šŸ“Š Confidence score: {result.params['confidence_score']:.2f}" + ) if "matched_keyword" in result.params: - print( - f"šŸ” Matched keyword: {result.params['matched_keyword']}") + print(f"šŸ” Matched keyword: {result.params['matched_keyword']}") if "chosen_child" in result.params: - print( - f"šŸŽÆ Chosen child: {result.params['chosen_child']}") + print(f"šŸŽÆ Chosen child: {result.params['chosen_child']}") else: print( - f"āŒ Failed: {result.error.message if result.error else 'Unknown error'}") + f"āŒ Failed: {result.error.message if result.error else 'Unknown error'}" + ) except Exception as e: print(f"šŸ’„ Exception: {type(e).__name__}: {str(e)}") diff --git a/examples/context_debug_demo.py b/examples/context_debug_demo.py index 588cc28..25597ab 100644 --- a/examples/context_debug_demo.py +++ b/examples/context_debug_demo.py @@ -6,7 +6,7 @@ import os from intent_kit import IntentGraphBuilder, handler, llm_classifier -from intent_kit import get_context_dependencies, validate_context_flow, trace_context_execution +from intent_kit import trace_context_execution from intent_kit.context import IntentContext from dotenv import load_dotenv @@ -16,7 +16,7 @@ LLM_CONFIG = { "provider": "openrouter", "api_key": os.getenv("OPENROUTER_API_KEY"), - "model": "meta-llama/llama-4-maverick-17b-128e-instruct" + "model": "meta-llama/llama-4-maverick-17b-128e-instruct", } @@ -28,7 +28,9 @@ def greet_handler(name: str, context: IntentContext) -> str: return f"Hello {name}! (Greeting #{count})" -def calculate_handler(operation: str, a: float, b: float, context: IntentContext) -> str: +def calculate_handler( + operation: str, a: float, b: float, context: IntentContext +) -> str: """Simple calculate handler with history.""" ops = {"add": "+", "plus": "+", "multiply": "*", "times": "*"} op = ops.get(operation.lower(), operation) @@ -50,7 +52,7 @@ def build_graph(): param_schema={"name": str}, llm_config=LLM_CONFIG, context_inputs={"greeting_count"}, - context_outputs={"greeting_count", "last_greeted"} + context_outputs={"greeting_count", "last_greeted"}, ), handler( name="calculate", @@ -59,13 +61,18 @@ def build_graph(): param_schema={"operation": str, "a": float, "b": float}, llm_config=LLM_CONFIG, context_inputs={"calc_history"}, - context_outputs={"calc_history"} + context_outputs={"calc_history"}, ), ] - classifier = llm_classifier( - name="root", children=handlers, llm_config=LLM_CONFIG) - return IntentGraphBuilder().root(classifier).debug_context(True).context_trace(True).build() + classifier = llm_classifier(name="root", children=handlers, llm_config=LLM_CONFIG) + return ( + IntentGraphBuilder() + .root(classifier) + .debug_context(True) + .context_trace(True) + .build() + ) def main(): @@ -81,7 +88,7 @@ def main(): "Hello, my name is Alice", "What's 5 plus 3?", "Hi again", - "Multiply 4 and 2" + "Multiply 4 and 2", ] for user_input in test_inputs: @@ -92,7 +99,8 @@ def main(): print(f"Output: {result.output}") print("Debug Context (Colorized Console format):") debug_output = trace_context_execution( - graph, user_input, context, "console") + graph, user_input, context, "console" + ) print(debug_output) # Also available: "json" format for plain JSON diff --git a/examples/context_demo.py b/examples/context_demo.py index 9cbd767..ea33bda 100644 --- a/examples/context_demo.py +++ b/examples/context_demo.py @@ -7,18 +7,18 @@ import os from datetime import datetime -from typing import Dict, Any, Optional, List from intent_kit.context import IntentContext from intent_kit import IntentGraphBuilder, handler, llm_classifier from intent_kit.services.llm_factory import LLMFactory from dotenv import load_dotenv + load_dotenv() # LLM configuration LLM_CONFIG = { "provider": "openrouter", "api_key": os.getenv("OPENROUTER_API_KEY"), - "model": "meta-llama/llama-4-maverick-17b-128e-instruct" + "model": "meta-llama/llama-4-maverick-17b-128e-instruct", } # Create LLM client @@ -34,13 +34,16 @@ def greet_handler(name: str, context: IntentContext) -> str: # Update context context.set("greeting_count", greeting_count + 1, modified_by="greet") context.set("last_greeted", name, modified_by="greet") - context.set("last_greeting_time", - datetime.now().isoformat(), modified_by="greet") + context.set("last_greeting_time", datetime.now().isoformat(), modified_by="greet") - return f"Hello {name}! (Greeting #{greeting_count + 1}, last greeted: {last_greeted})" + return ( + f"Hello {name}! (Greeting #{greeting_count + 1}, last greeted: {last_greeted})" + ) -def calculate_handler(operation: str, a: float, b: float, context: IntentContext) -> str: +def calculate_handler( + operation: str, a: float, b: float, context: IntentContext +) -> str: """Calculate handler with history tracking.""" result = None @@ -69,16 +72,21 @@ def calculate_handler(operation: str, a: float, b: float, context: IntentContext # Store calculation history in context calc_history = context.get("calculation_history", []) - calc_history.append({ - "operation": operation_display, - "a": a, - "b": b, - "result": result, - "timestamp": datetime.now().isoformat() - }) + calc_history.append( + { + "operation": operation_display, + "a": a, + "b": b, + "result": result, + "timestamp": datetime.now().isoformat(), + } + ) context.set("calculation_history", calc_history, modified_by="calculate") - context.set("last_calculation", - f"{a} {operation_display} {b} = {result}", modified_by="calculate") + context.set( + "last_calculation", + f"{a} {operation_display} {b} = {result}", + modified_by="calculate", + ) return f"{a} {operation_display} {b} = {result}" @@ -88,18 +96,21 @@ def weather_handler(location: str, context: IntentContext) -> str: # Check if we've already fetched weather for this location recently last_weather = context.get("last_weather", {}) if last_weather.get("location") == location: - time_diff = datetime.now().isoformat() return f"Weather in {location}: {last_weather.get('data')} (cached)" # Simulate weather data weather_data = f"72°F, Sunny (simulated for {location})" # Store in context - context.set("last_weather", { - "location": location, - "data": weather_data, - "timestamp": datetime.now().isoformat() - }, modified_by="weather") + context.set( + "last_weather", + { + "location": location, + "data": weather_data, + "timestamp": datetime.now().isoformat(), + }, + modified_by="weather", + ) return f"Weather in {location}: {weather_data}" @@ -131,8 +142,7 @@ def build_context_aware_tree(): param_schema={"name": str}, llm_config=LLM_CONFIG, context_inputs={"greeting_count", "last_greeted"}, - context_outputs={"greeting_count", - "last_greeted", "last_greeting_time"} + context_outputs={"greeting_count", "last_greeted", "last_greeting_time"}, ) calc_handler_node = handler( @@ -142,7 +152,7 @@ def build_context_aware_tree(): param_schema={"operation": str, "a": float, "b": float}, llm_config=LLM_CONFIG, context_inputs={"calculation_history"}, - context_outputs={"calculation_history", "last_calculation"} + context_outputs={"calculation_history", "last_calculation"}, ) weather_handler_node = handler( @@ -152,7 +162,7 @@ def build_context_aware_tree(): param_schema={"location": str}, llm_config=LLM_CONFIG, context_inputs={"last_weather"}, - context_outputs={"last_weather"} + context_outputs={"last_weather"}, ) history_handler_node = handler( @@ -161,7 +171,7 @@ def build_context_aware_tree(): handler_func=show_calculation_history_handler, param_schema={}, llm_config=LLM_CONFIG, - context_inputs={"calculation_history"} + context_inputs={"calculation_history"}, ) help_handler_node = handler( @@ -169,7 +179,7 @@ def build_context_aware_tree(): description="Get help", handler_func=lambda: "I can help you with greetings, calculations, weather, and showing history!", param_schema={}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ) # Create classifier with auto-wired children descriptions @@ -180,10 +190,10 @@ def build_context_aware_tree(): calc_handler_node, weather_handler_node, history_handler_node, - help_handler_node + help_handler_node, ], llm_config=LLM_CONFIG, - description="LLM-powered intent classifier with context support" + description="LLM-powered intent classifier with context support", ) @@ -191,17 +201,13 @@ def main(): print("IntentKit Context Demo") print("This demo shows how context can be shared between workflow steps.") print("You must set a valid API key in LLM_CONFIG for this to work.") - print("\n" + "="*50) + print("\n" + "=" * 50) # Create context for the session context = IntentContext(session_id="demo_user_123", debug=True) # Create IntentGraph using the new builder pattern - graph = ( - IntentGraphBuilder() - .root(build_context_aware_tree()) - .build() - ) + graph = IntentGraphBuilder().root(build_context_aware_tree()).build() # Test sequence showing context persistence test_sequence = [ @@ -211,7 +217,7 @@ def main(): "Hi again", # Should show greeting count "What's 8 times 3?", "Weather in San Francisco again", # Should show cached result - "What was my last calculation?" # Should show context access + "What was my last calculation?", # Should show context access ] for i, user_input in enumerate(test_sequence, 1): @@ -229,30 +235,29 @@ def main(): # Show execution path if available if result.children_results: - print(f" Execution Path:") + print(" Execution Path:") for i, child_result in enumerate(result.children_results): - path_str = '.'.join(child_result.node_path) + path_str = ".".join(child_result.node_path) print( - f" {i+1}. {child_result.node_name} ({child_result.node_type}) - Path: {path_str}") + f" {i+1}. {child_result.node_name} ({child_result.node_type}) - Path: {path_str}" + ) if child_result.params: - print( - f" Params: {child_result.params}") + print(f" Params: {child_result.params}") if child_result.output: - print( - f" Output: {child_result.output}") + print(f" Output: {child_result.output}") if child_result.error: print(f" Error: {child_result.error}") # Show context state after execution - print(f" Context state:") - print( - f" Greeting count: {context.get('greeting_count', 0)}") - print( - f" Last greeted: {context.get('last_greeted', 'None')}") + print(" Context state:") + print(f" Greeting count: {context.get('greeting_count', 0)}") + print(f" Last greeted: {context.get('last_greeted', 'None')}") print( - f" Calc history: {len(context.get('calculation_history', []))} entries") + f" Calc history: {len(context.get('calculation_history', []))} entries" + ) print( - f" Last weather: {context.get('last_weather', {}).get('location', 'None')}") + f" Last weather: {context.get('last_weather', {}).get('location', 'None')}" + ) else: print(f" Error: {result.error}") @@ -266,30 +271,30 @@ def main(): print(f" Context errors: {len(errors)} total") for error in errors[-2:]: # Show last 2 errors print( - f" [{error.timestamp.strftime('%H:%M:%S')}] {error.node_name}: {error.error_message}") + f" [{error.timestamp.strftime('%H:%M:%S')}] {error.node_name}: {error.error_message}" + ) except Exception as e: print(f" Error: {e}") # Show final context state - print(f"\n--- Final Context State ---") + print("\n--- Final Context State ---") print(f"Session ID: {context.session_id}") print(f"Total fields: {len(context.keys())}") print(f"History entries: {len(context.get_history())}") print(f"Error count: {context.error_count()}") # Show some context history - print(f"\n--- Context History (last 5 entries) ---") + print("\n--- Context History (last 5 entries) ---") for entry in context.get_history(limit=5): print(f" {entry.timestamp}: {entry.action} '{entry.key}' = {entry.value}") # Show recent errors if any errors = context.get_errors(limit=3) if errors: - print(f"\n--- Recent Errors (last 3) ---") + print("\n--- Recent Errors (last 3) ---") for error in errors: - print( - f" [{error.timestamp.strftime('%H:%M:%S')}] {error.node_name}") + print(f" [{error.timestamp.strftime('%H:%M:%S')}] {error.node_name}") print(f" Input: {error.user_input}") print(f" Error: {error.error_message}") if error.params: diff --git a/examples/error_demo.py b/examples/error_demo.py index 6a50660..032f0ac 100644 --- a/examples/error_demo.py +++ b/examples/error_demo.py @@ -7,7 +7,6 @@ """ from intent_kit import handler -from intent_kit.builder import handler, llm_classifier from intent_kit.classifiers.keyword import keyword_classifier @@ -20,7 +19,7 @@ def extract_args(user_input: str, context=None) -> dict: return { "name": words[0] if words else "", - "age": words[1] if len(words) > 1 else "" + "age": words[1] if len(words) > 1 else "", } @@ -47,17 +46,18 @@ def main(): name="Greet", description="Greet someone with their name and age", handler_func=greet_handler, - param_schema={"name": str, "age": int} + param_schema={"name": str, "age": int}, # No llm_config = uses rule-based extraction ) # Create a classifier node manually since we need a custom classifier from intent_kit.classifiers import ClassifierNode + root_node = ClassifierNode( name="Root", classifier=keyword_classifier, children=[greet_handler_node], - description="Demo intent tree" + description="Demo intent tree", ) # Set parent reference @@ -65,11 +65,11 @@ def main(): # Test cases that will trigger different types of errors test_cases = [ - "Greet John 30", # Success - "Greet", # Validation failure (missing age) - "Greet John -5", # Handler error (negative age) - "Greet John 200", # Handler error (unrealistic age) - "Greet John abc", # Type validation error (age not a number) + "Greet John 30", # Success + "Greet", # Validation failure (missing age) + "Greet John -5", # Handler error (negative age) + "Greet John 200", # Handler error (unrealistic age) + "Greet John abc", # Type validation error (age not a number) ] for user_input in test_cases: @@ -89,7 +89,6 @@ def main(): for step in result.children_results: node_name = step.node_name node_type = step.node_type - success = step.success if step.error: # Now we have rich error information! diff --git a/examples/eval_api_demo.py b/examples/eval_api_demo.py index 06362c5..6e4aa12 100644 --- a/examples/eval_api_demo.py +++ b/examples/eval_api_demo.py @@ -5,24 +5,26 @@ Demonstration of the new intent-kit evaluation API. """ +from dotenv import load_dotenv from intent_kit.evals import ( load_dataset, run_eval, run_eval_from_path, run_eval_from_module, EvalTestCase, - Dataset + Dataset, ) from intent_kit.evals.sample_nodes.classifier_node_llm import classifier_node_llm +load_dotenv() + def demo_basic_usage(): """Demonstrate basic usage with direct node instance.""" print("=== Basic Usage Demo ===") # Load dataset - dataset = load_dataset( - "intent_kit/evals/datasets/classifier_node_llm.yaml") + dataset = load_dataset("intent_kit/evals/datasets/classifier_node_llm.yaml") print(f"Loaded dataset: {dataset.name}") print(f"Test cases: {len(dataset.test_cases)}") @@ -37,7 +39,7 @@ def demo_basic_usage(): json_path = result.save_json() md_path = result.save_markdown() - print(f"Results saved to:") + print("Results saved to:") print(f" CSV: {csv_path}") print(f" JSON: {json_path}") print(f" Markdown: {md_path}") @@ -49,8 +51,7 @@ def demo_from_path(): print("\n=== From Path Demo ===") result = run_eval_from_path( - "intent_kit/evals/datasets/classifier_node_llm.yaml", - classifier_node_llm + "intent_kit/evals/datasets/classifier_node_llm.yaml", classifier_node_llm ) result.print_summary() @@ -64,7 +65,7 @@ def demo_from_module(): result = run_eval_from_module( "intent_kit/evals/datasets/classifier_node_llm.yaml", "intent_kit.evals.sample_nodes.classifier_node_llm", - "classifier_node_llm" + "classifier_node_llm", ) result.print_summary() @@ -84,7 +85,7 @@ def case_insensitive_comparator(expected, actual): result = run_eval_from_path( "intent_kit/evals/datasets/classifier_node_llm.yaml", classifier_node_llm, - comparator=case_insensitive_comparator + comparator=case_insensitive_comparator, ) result.print_summary() @@ -98,7 +99,7 @@ def demo_fail_fast(): result = run_eval_from_path( "intent_kit/evals/datasets/classifier_node_llm.yaml", classifier_node_llm, - fail_fast=True + fail_fast=True, ) print(f"Fail-fast evaluation completed with {result.total_count()} tests") @@ -114,13 +115,13 @@ def demo_programmatic_dataset(): EvalTestCase( input="What's the weather like in Paris?", expected="Weather in Paris: Sunny with a chance of rain", - context={"user_id": "demo_user"} + context={"user_id": "demo_user"}, ), EvalTestCase( input="Cancel my flight", expected="Successfully cancelled flight", - context={"user_id": "demo_user"} - ) + context={"user_id": "demo_user"}, + ), ] # Create dataset @@ -129,7 +130,7 @@ def demo_programmatic_dataset(): description="Programmatically created test dataset", node_type="classifier", node_name="classifier_node_llm", - test_cases=test_cases + test_cases=test_cases, ) # Run evaluation @@ -152,15 +153,9 @@ def broken_node(input_text, context=None): # Create a simple test case test_cases = [ EvalTestCase( - input="What's the weather like?", - expected="Weather response", - context={} + input="What's the weather like?", expected="Weather response", context={} ), - EvalTestCase( - input="Hello there", - expected="Default response", - context={} - ) + EvalTestCase(input="Hello there", expected="Default response", context={}), ] dataset = Dataset( @@ -168,7 +163,7 @@ def broken_node(input_text, context=None): description="Testing error handling", node_type="test", node_name="broken_node", - test_cases=test_cases + test_cases=test_cases, ) result = run_eval(dataset, broken_node) @@ -192,7 +187,7 @@ def main(): demo_custom_comparator, demo_fail_fast, demo_programmatic_dataset, - demo_error_handling + demo_error_handling, ] results = [] diff --git a/examples/multi_intent_demo.py b/examples/multi_intent_demo.py index ea0a223..109f108 100644 --- a/examples/multi_intent_demo.py +++ b/examples/multi_intent_demo.py @@ -17,7 +17,7 @@ LLM_CONFIG = { "provider": "openrouter", "api_key": os.getenv("OPENROUTER_API_KEY"), - "model": "meta-llama/llama-4-maverick-17b-128e-instruct" + "model": "meta-llama/llama-4-maverick-17b-128e-instruct", } # Configure your intent graph here @@ -36,7 +36,7 @@ def _calculate_handler(operation: str, a: float, b: float) -> str: "multiplied": "*", "divided": "/", "divide": "/", - "over": "/" + "over": "/", } # Get the mathematical operator @@ -59,29 +59,30 @@ def create_intent_graph(): description="Greet the user", handler_func=lambda name, **kwargs: f"Hello {name}!", param_schema={"name": str}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ), handler( name="calculate", description="Perform a calculation", handler_func=lambda operation, a, b, **kwargs: _calculate_handler( - operation, a, b), + operation, a, b + ), param_schema={"operation": str, "a": float, "b": float}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ), handler( name="weather", description="Get weather information", handler_func=lambda location, **kwargs: f"Weather in {location}: 72°F, Sunny (simulated)", param_schema={"location": str}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ), handler( name="help", description="Get help", handler_func=lambda **kwargs: "I can help with greetings, calculations, and weather!", - param_schema={} - ) + param_schema={}, + ), ] # Create classifier @@ -89,7 +90,7 @@ def create_intent_graph(): name="root", children=handlers, llm_config=LLM_CONFIG, - description="Main intent classifier" + description="Main intent classifier", ) # Build and return the graph with LLM-powered splitter for intelligent multi-intent handling @@ -108,7 +109,7 @@ def create_intent_graph(): "Hello, my name is Alice", "What's 15 plus 7?", "Weather in San Francisco", - "Help me" + "Help me", ] print("=== Single Intent Tests ===") @@ -126,7 +127,7 @@ def create_intent_graph(): "Hello Alice and what's the weather in San Francisco", "Calculate 5 plus 3 and also greet Bob", "Help me and get weather for New York", - "Greet John, calculate 10 times 2, and weather in London" + "Greet John, calculate 10 times 2, and weather in London", ] print("\n=== Multi-Intent Tests ===") diff --git a/examples/ollama_demo.py b/examples/ollama_demo.py index 6959ece..650baec 100644 --- a/examples/ollama_demo.py +++ b/examples/ollama_demo.py @@ -13,7 +13,7 @@ OLLAMA_CONFIG = { "provider": "ollama", "model": "gemma3:27b", # Change this to your available model - "base_url": "http://localhost:11434" + "base_url": "http://localhost:11434", } # Configure your intent graph here @@ -29,36 +29,36 @@ def create_intent_graph(): description="Greet the user", handler_func=_greet_handler, param_schema={"name": str}, - llm_config=OLLAMA_CONFIG + llm_config=OLLAMA_CONFIG, ), handler( name="calculate", description="Perform a calculation", handler_func=_calculate_handler, param_schema={"operation": str, "a": float, "b": float}, - llm_config=OLLAMA_CONFIG + llm_config=OLLAMA_CONFIG, ), handler( name="weather", description="Get weather information", handler_func=_weather_handler, param_schema={"location": str}, - llm_config=OLLAMA_CONFIG + llm_config=OLLAMA_CONFIG, ), handler( name="history", description="Show calculation history", handler_func=_history_handler, param_schema={}, - llm_config=OLLAMA_CONFIG + llm_config=OLLAMA_CONFIG, ), handler( name="help", description="Get help", handler_func=lambda **kwargs: "I can help with greetings, calculations, weather, and history!", param_schema={}, - llm_config=OLLAMA_CONFIG - ) + llm_config=OLLAMA_CONFIG, + ), ] # Create classifier @@ -66,7 +66,7 @@ def create_intent_graph(): name="root", children=handlers, llm_config=OLLAMA_CONFIG, - description="Ollama-powered intent classifier" + description="Ollama-powered intent classifier", ) # Build and return the graph @@ -81,7 +81,9 @@ def _greet_handler(name: str, context: IntentContext) -> str: return f"Hello {name}! (Greeting #{greeting_count})" -def _calculate_handler(operation: str, a: float, b: float, context: IntentContext) -> str: +def _calculate_handler( + operation: str, a: float, b: float, context: IntentContext +) -> str: if operation.lower() in ["add", "plus", "+"]: result = a + b op_display = "plus" @@ -93,13 +95,15 @@ def _calculate_handler(operation: str, a: float, b: float, context: IntentContex # Store in context calc_history = context.get("calculation_history", []) - calc_history.append({ - "operation": op_display, - "a": a, - "b": b, - "result": result, - "timestamp": datetime.now().isoformat() - }) + calc_history.append( + { + "operation": op_display, + "a": a, + "b": b, + "result": result, + "timestamp": datetime.now().isoformat(), + } + ) context.set("calculation_history", calc_history, modified_by="calculate") return f"{a} {op_display} {b} = {result}" @@ -112,11 +116,15 @@ def _weather_handler(location: str, context: IntentContext) -> str: return f"Weather in {location}: {last_weather.get('data')} (cached)" weather_data = f"72°F, Sunny (simulated for {location})" - context.set("last_weather", { - "location": location, - "data": weather_data, - "timestamp": datetime.now().isoformat() - }, modified_by="weather") + context.set( + "last_weather", + { + "location": location, + "data": weather_data, + "timestamp": datetime.now().isoformat(), + }, + modified_by="weather", + ) return f"Weather in {location}: {weather_data}" @@ -133,7 +141,9 @@ def _history_handler(context: IntentContext) -> str: # Test the graph if __name__ == "__main__": print("Ollama Demo - Local LLM IntentGraph") - print("Make sure Ollama is running and you have a model pulled (e.g., 'ollama pull gemma3:27b')") + print( + "Make sure Ollama is running and you have a model pulled (e.g., 'ollama pull gemma3:27b')" + ) print() graph = create_intent_graph() @@ -145,7 +155,7 @@ def _history_handler(context: IntentContext) -> str: "Weather in San Francisco", "What was my last calculation?", "Multiply 8 and 3", - "Help me" + "Help me", ] for user_input in test_inputs: diff --git a/examples/remediation_demo.py b/examples/remediation_demo.py index 9739f58..c13b98f 100644 --- a/examples/remediation_demo.py +++ b/examples/remediation_demo.py @@ -15,9 +15,7 @@ from intent_kit.utils.logger import Logger from intent_kit.handlers.remediation import ( RemediationStrategy, - create_retry_strategy, - create_fallback_strategy, - register_remediation_strategy + register_remediation_strategy, ) from intent_kit.node.types import ExecutionResult, ExecutionError from intent_kit.context import IntentContext @@ -26,6 +24,7 @@ import os from typing import Optional from dotenv import load_dotenv + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Load environment variables @@ -39,11 +38,13 @@ LLM_CONFIG = { "provider": "openai", "model": "gpt-4.1-mini", - "api_key": os.getenv("OPENAI_API_KEY") + "api_key": os.getenv("OPENAI_API_KEY"), } -def unreliable_calculator(operation: str, a: float, b: float, context: IntentContext) -> str: +def unreliable_calculator( + operation: str, a: float, b: float, context: IntentContext +) -> str: """ A deliberately unreliable calculator that fails randomly to demonstrate remediation. """ @@ -52,7 +53,8 @@ def unreliable_calculator(operation: str, a: float, b: float, context: IntentCon # Simulate random failures (30% failure rate) if random.random() < 0.3: raise Exception( - "Random calculation failure - this is expected for demo purposes") + "Random calculation failure - this is expected for demo purposes" + ) ops = {"add": "+", "plus": "+", "multiply": "*", "times": "*"} op = ops.get(operation.lower(), operation) @@ -65,7 +67,9 @@ def unreliable_calculator(operation: str, a: float, b: float, context: IntentCon return f"{a} {operation} {b} = {result}" -def reliable_calculator(operation: str, a: float, b: float, context: IntentContext) -> str: +def reliable_calculator( + operation: str, a: float, b: float, context: IntentContext +) -> str: """ A reliable fallback calculator that always works. """ @@ -97,11 +101,10 @@ def execute( user_input: str, context: Optional[IntentContext] = None, original_error: Optional[ExecutionError] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Log the error and return a simple message.""" - self.logger.info( - f"LogAndContinueStrategy: Handling error for {node_name}") + self.logger.info(f"LogAndContinueStrategy: Handling error for {node_name}") from intent_kit.node.types import ExecutionResult from intent_kit.node.enums import NodeType @@ -114,11 +117,13 @@ def execute( input=user_input, output=f"Operation completed with warnings (original error: {original_error.message if original_error else 'unknown'})", error=None, - params=kwargs.get('validated_params', {}), - children_results=[] + params=kwargs.get("validated_params", {}), + children_results=[], ) - return LogAndContinueStrategy("log_and_continue", "Log error and continue with warning") + return LogAndContinueStrategy( + "log_and_continue", "Log error and continue with warning" + ) def create_intent_graph(): @@ -139,9 +144,8 @@ def create_intent_graph(): llm_config=LLM_CONFIG, context_inputs={"calc_history"}, context_outputs={"calc_history"}, - remediation_strategies=["retry_on_fail"] # Built-in retry strategy + remediation_strategies=["retry_on_fail"], # Built-in retry strategy ), - # Handler with fallback strategy handler( name="reliable_calc", @@ -152,9 +156,8 @@ def create_intent_graph(): context_inputs={"calc_history"}, context_outputs={"calc_history"}, # Built-in fallback strategy - remediation_strategies=["fallback_to_another_node"] + remediation_strategies=["fallback_to_another_node"], ), - # Handler with custom remediation strategy handler( name="simple_greet", @@ -164,8 +167,8 @@ def create_intent_graph(): llm_config=LLM_CONFIG, context_inputs={"greeting_count"}, context_outputs={"greeting_count"}, - remediation_strategies=["log_and_continue"] # Custom strategy - ) + remediation_strategies=["log_and_continue"], # Custom strategy + ), ] # Create classifier @@ -173,7 +176,7 @@ def create_intent_graph(): name="root", children=handlers, llm_config=LLM_CONFIG, - description="Main intent classifier with remediation" + description="Main intent classifier with remediation", ) # Build and return the graph @@ -197,7 +200,7 @@ def run_demo(): "What is 10 times 2?", "Hello Alice", "Add 7 and 4", - "Multiply 3 by 6" + "Multiply 3 by 6", ] print("\nšŸ“‹ Test Cases:") @@ -214,7 +217,8 @@ def run_demo(): print(f"āœ… Success: {result.output}") else: print( - f"āŒ Failed: {result.error.message if result.error else 'Unknown error'}") + f"āŒ Failed: {result.error.message if result.error else 'Unknown error'}" + ) except Exception as e: print(f"šŸ’„ Exception: {type(e).__name__}: {str(e)}") diff --git a/examples/simple_demo.ipynb b/examples/simple_demo.ipynb new file mode 100644 index 0000000..fad8edb --- /dev/null +++ b/examples/simple_demo.ipynb @@ -0,0 +1,27 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "f98711a1", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"Hello, World!\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/simple_demo.py b/examples/simple_demo.py index 28617a8..8b950d6 100644 --- a/examples/simple_demo.py +++ b/examples/simple_demo.py @@ -14,7 +14,7 @@ LLM_CONFIG = { "provider": "openrouter", "api_key": os.getenv("OPENROUTER_API_KEY"), - "model": "meta-llama/llama-4-maverick-17b-128e-instruct" + "model": "meta-llama/llama-4-maverick-17b-128e-instruct", } # Configure your intent graph here @@ -33,7 +33,7 @@ def _calculate_handler(operation: str, a: float, b: float) -> str: "multiplied": "*", "divided": "/", "divide": "/", - "over": "/" + "over": "/", } # Get the mathematical operator @@ -56,30 +56,31 @@ def create_intent_graph(): description="Greet the user", handler_func=lambda name, **kwargs: f"Hello {name}!", param_schema={"name": str}, - llm_config=LLM_CONFIG # Remove this line for rule-based extraction + llm_config=LLM_CONFIG, # Remove this line for rule-based extraction ), handler( name="calculate", description="Perform a calculation", handler_func=lambda operation, a, b, **kwargs: _calculate_handler( - operation, a, b), + operation, a, b + ), param_schema={"operation": str, "a": float, "b": float}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ), handler( name="weather", description="Get weather information", handler_func=lambda location, **kwargs: f"Weather in {location}: 72°F, Sunny (simulated)", param_schema={"location": str}, - llm_config=LLM_CONFIG + llm_config=LLM_CONFIG, ), handler( name="help", description="Get help", handler_func=lambda **kwargs: "I can help with greetings, calculations, and weather!", param_schema={}, - llm_config=LLM_CONFIG - ) + llm_config=LLM_CONFIG, + ), ] # Create classifier @@ -87,7 +88,7 @@ def create_intent_graph(): name="root", children=handlers, llm_config=LLM_CONFIG, - description="Main intent classifier" + description="Main intent classifier", ) # Build and return the graph (uses default pass-through splitter) @@ -106,7 +107,7 @@ def create_intent_graph(): "What's 15 plus 7?", "Weather in San Francisco", "Help me", - "Multiply 8 and 3" + "Multiply 8 and 3", ] for user_input in test_inputs: diff --git a/intent_kit/__init__.py b/intent_kit/__init__.py index 65bddca..4421c85 100644 --- a/intent_kit/__init__.py +++ b/intent_kit/__init__.py @@ -13,10 +13,21 @@ from .classifiers import ClassifierNode from .handlers import HandlerNode from .splitters import SplitterNode -from .builder import IntentGraphBuilder, handler, llm_classifier, llm_splitter_node, rule_splitter_node, create_intent_graph +from .builder import ( + IntentGraphBuilder, + handler, + llm_classifier, + llm_splitter_node, + rule_splitter_node, + create_intent_graph, +) from .graph import IntentGraph from .context import IntentContext -from .context.debug import get_context_dependencies, validate_context_flow, trace_context_execution +from .context.debug import ( + get_context_dependencies, + validate_context_flow, + trace_context_execution, +) from .classifiers import keyword_classifier from .classifiers.llm_classifier import create_llm_classifier, create_llm_arg_extractor from .splitters import rule_splitter, llm_splitter @@ -26,37 +37,31 @@ __all__ = [ # Core components - 'HandlerNode', - 'TreeNode', - 'NodeType', - 'ClassifierNode', - 'SplitterNode', - - 'IntentGraph', - 'IntentContext', - + "HandlerNode", + "TreeNode", + "NodeType", + "ClassifierNode", + "SplitterNode", + "IntentGraph", + "IntentContext", # Classifiers - 'keyword_classifier', - 'create_llm_classifier', - 'create_llm_arg_extractor', - + "keyword_classifier", + "create_llm_classifier", + "create_llm_arg_extractor", # Splitters - 'rule_splitter', - 'llm_splitter', - + "rule_splitter", + "llm_splitter", # Services - 'LLMFactory', - + "LLMFactory", # New high-level API (recommended) - 'IntentGraphBuilder', - 'handler', - 'llm_classifier', - 'llm_splitter_node', - 'rule_splitter_node', - 'create_intent_graph', - + "IntentGraphBuilder", + "handler", + "llm_classifier", + "llm_splitter_node", + "rule_splitter_node", + "create_intent_graph", # Context debugging utilities - 'get_context_dependencies', - 'validate_context_flow', - 'trace_context_execution', + "get_context_dependencies", + "validate_context_flow", + "trace_context_execution", ] diff --git a/intent_kit/builder.py b/intent_kit/builder.py index 891c90d..54ba53f 100644 --- a/intent_kit/builder.py +++ b/intent_kit/builder.py @@ -7,11 +7,12 @@ create_llm_classifier, create_llm_arg_extractor, get_default_classification_prompt, - get_default_extraction_prompt + get_default_extraction_prompt, ) from .types import IntentChunk from .graph import IntentGraph from .utils.logger import Logger + # Import splitter functions for builder methods from .splitters.functions import rule_splitter, llm_splitter from .handlers.remediation import RemediationStrategy @@ -29,7 +30,7 @@ def __init__(self): self._debug_context = False self._context_trace = False - def root(self, node: TreeNode) -> 'IntentGraphBuilder': + def root(self, node: TreeNode) -> "IntentGraphBuilder": """Set the root node for the intent graph. Args: @@ -41,7 +42,7 @@ def root(self, node: TreeNode) -> 'IntentGraphBuilder': self._root_node = node return self - def splitter(self, splitter_func) -> 'IntentGraphBuilder': + def splitter(self, splitter_func) -> "IntentGraphBuilder": """Set a custom splitter function for the intent graph. Args: @@ -69,17 +70,16 @@ def build(self) -> IntentGraph: graph = IntentGraph( splitter=self._splitter, debug_context=self._debug_context, - context_trace=self._context_trace + context_trace=self._context_trace, ) else: graph = IntentGraph( - debug_context=self._debug_context, - context_trace=self._context_trace + debug_context=self._debug_context, context_trace=self._context_trace ) graph.add_root_node(self._root_node) return graph - def debug_context(self, enabled: bool = True) -> 'IntentGraphBuilder': + def debug_context(self, enabled: bool = True) -> "IntentGraphBuilder": """Enable context debugging for the intent graph. Args: @@ -91,7 +91,7 @@ def debug_context(self, enabled: bool = True) -> 'IntentGraphBuilder': self._debug_context = enabled return self - def context_trace(self, enabled: bool = True) -> 'IntentGraphBuilder': + def context_trace(self, enabled: bool = True) -> "IntentGraphBuilder": """Enable detailed context tracing for the intent graph. Args: @@ -116,8 +116,7 @@ def handler( context_outputs: Optional[Set[str]] = None, input_validator: Optional[Callable[[Dict[str, Any]], bool]] = None, output_validator: Optional[Callable[[Any], bool]] = None, - remediation_strategies: Optional[List[Union[str, - "RemediationStrategy"]]] = None + remediation_strategies: Optional[List[Union[str, "RemediationStrategy"]]] = None, ) -> TreeNode: """Create a handler node with automatic argument extraction. @@ -157,15 +156,17 @@ def handler( ) else: # Use simple rule-based extraction as fallback - def simple_arg_extractor(text: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def simple_arg_extractor( + text: str, context: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Simple rule-based argument extractor.""" extracted = {} # For each parameter, try to extract it using simple rules for param_name, param_type in param_schema.items(): - if param_type == str: + if isinstance(param_type, type) and param_type is str: # For string parameters, try to find relevant text - if param_name.lower() in ['name', 'location', 'operation']: + if param_name.lower() in ["name", "location", "operation"]: # Extract the last word as a simple heuristic words = text.split() if words: @@ -173,35 +174,36 @@ def simple_arg_extractor(text: str, context: Optional[Dict[str, Any]] = None) -> else: # Default: use the entire text for string params extracted[param_name] = text.strip() - elif param_type in [int, float]: + elif isinstance(param_type, type) and param_type in [int, float]: # For numeric parameters, try to find numbers in text import re - numbers = re.findall(r'\d+(?:\.\d+)?', text) + + numbers = re.findall(r"\d+(?:\.\d+)?", text) if numbers: try: extracted[param_name] = param_type(numbers[0]) except (ValueError, IndexError): # Use default values for common parameters - if param_name in ['a', 'first']: + if param_name in ["a", "first"]: extracted[param_name] = param_type(10) - elif param_name in ['b', 'second']: + elif param_name in ["b", "second"]: extracted[param_name] = param_type(5) else: extracted[param_name] = param_type(0) else: # Use default values - if param_name in ['a', 'first']: + if param_name in ["a", "first"]: extracted[param_name] = param_type(10) - elif param_name in ['b', 'second']: + elif param_name in ["b", "second"]: extracted[param_name] = param_type(5) else: extracted[param_name] = param_type(0) else: # For other types, use a default value - if param_type == bool: - extracted[param_name] = True + if isinstance(param_type, type) and param_type is bool: + extracted[param_name] = True # type: ignore else: - extracted[param_name] = None + extracted[param_name] = None # type: ignore return extracted @@ -217,7 +219,7 @@ def simple_arg_extractor(text: str, context: Optional[Dict[str, Any]] = None) -> input_validator=input_validator, output_validator=output_validator, description=description, - remediation_strategies=remediation_strategies + remediation_strategies=remediation_strategies, ) @@ -228,8 +230,7 @@ def llm_classifier( llm_config: Dict[str, Any], classification_prompt: Optional[str] = None, description: str = "", - remediation_strategies: Optional[List[Union[str, - "RemediationStrategy"]]] = None + remediation_strategies: Optional[List[Union[str, "RemediationStrategy"]]] = None, ) -> TreeNode: """Create an LLM-powered classifier node with auto-wired children descriptions. @@ -256,13 +257,14 @@ def llm_classifier( # Auto-wire children descriptions for the classifier node_descriptions = [] for child in children: - if hasattr(child, 'description') and child.description: + if hasattr(child, "description") and child.description: node_descriptions.append(f"{child.name}: {child.description}") else: # Use name as fallback if no description node_descriptions.append(child.name) logger.warning( - f"Child node '{child.name}' has no description, using name as fallback") + f"Child node '{child.name}' has no description, using name as fallback" + ) if not classification_prompt: classification_prompt = get_default_classification_prompt() @@ -276,7 +278,7 @@ def llm_classifier( classifier=classifier, children=children, description=description, - remediation_strategies=remediation_strategies + remediation_strategies=remediation_strategies, ) # Set parent reference for all children to this classifier node @@ -291,7 +293,7 @@ def llm_splitter_node( name: str, children: List[TreeNode], llm_config: Dict[str, Any], - description: str = "" + description: str = "", ) -> TreeNode: """Create an LLM-powered splitter node for multi-intent handling. @@ -311,10 +313,13 @@ def llm_splitter_node( ... llm_config=LLM_CONFIG ... ) """ + # Create a wrapper function that provides the LLM client to llm_splitter - def llm_splitter_wrapper(user_input: str, debug: bool = False) -> Sequence[IntentChunk]: + def llm_splitter_wrapper( + user_input: str, debug: bool = False + ) -> Sequence[IntentChunk]: # Extract LLM client from config - llm_client = llm_config.get('llm_client') + llm_client = llm_config.get("llm_client") return llm_splitter(user_input, debug, llm_client) splitter_node = SplitterNode( @@ -322,7 +327,7 @@ def llm_splitter_wrapper(user_input: str, debug: bool = False) -> Sequence[Inten splitter_function=llm_splitter_wrapper, children=children, description=description, - llm_client=llm_config.get('llm_client') + llm_client=llm_config.get("llm_client"), ) # Set parent reference for all children to this splitter node @@ -333,10 +338,7 @@ def llm_splitter_wrapper(user_input: str, debug: bool = False) -> Sequence[Inten def rule_splitter_node( - *, - name: str, - children: List[TreeNode], - description: str = "" + *, name: str, children: List[TreeNode], description: str = "" ) -> TreeNode: """Create a rule-based splitter node for multi-intent handling. @@ -358,7 +360,7 @@ def rule_splitter_node( name=name, splitter_function=rule_splitter, children=children, - description=description + description=description, ) # Set parent reference for all children to this splitter node @@ -369,9 +371,7 @@ def rule_splitter_node( # Convenience function for creating a complete graph -def create_intent_graph( - root_node: TreeNode -) -> IntentGraph: +def create_intent_graph(root_node: TreeNode) -> IntentGraph: """Create an IntentGraph with the given root node. Args: diff --git a/intent_kit/classifiers/__init__.py b/intent_kit/classifiers/__init__.py index 3750147..52ca642 100644 --- a/intent_kit/classifiers/__init__.py +++ b/intent_kit/classifiers/__init__.py @@ -7,18 +7,22 @@ from .node import ClassifierNode from .keyword import keyword_classifier -from .llm_classifier import create_llm_classifier, create_llm_arg_extractor, get_default_classification_prompt, get_default_extraction_prompt +from .llm_classifier import ( + create_llm_classifier, + create_llm_arg_extractor, + get_default_classification_prompt, + get_default_extraction_prompt, +) from .chunk_classifier import classify_intent_chunk __all__ = [ # Node class "ClassifierNode", - # Classifier functions "keyword_classifier", "create_llm_classifier", "create_llm_arg_extractor", "get_default_classification_prompt", "get_default_extraction_prompt", - "classify_intent_chunk" + "classify_intent_chunk", ] diff --git a/intent_kit/classifiers/chunk_classifier.py b/intent_kit/classifiers/chunk_classifier.py index f2ad336..f8f79a1 100644 --- a/intent_kit/classifiers/chunk_classifier.py +++ b/intent_kit/classifiers/chunk_classifier.py @@ -1,18 +1,25 @@ """ LLM-powered chunk classifier for intent chunks. """ -from intent_kit.types import IntentChunk, IntentChunkClassification, IntentClassification, IntentAction, ClassifierOutput + +from intent_kit.types import ( + IntentChunk, + IntentClassification, + IntentAction, + ClassifierOutput, +) from intent_kit.services.llm_factory import LLMFactory from intent_kit.utils.logger import Logger from intent_kit.utils.text_utils import extract_json_from_text, extract_key_value_pairs -import json import re from typing import Optional logger = Logger(__name__) -def classify_intent_chunk(chunk: IntentChunk, llm_config: Optional[dict] = None) -> ClassifierOutput: +def classify_intent_chunk( + chunk: IntentChunk, llm_config: Optional[dict] = None +) -> ClassifierOutput: """ LLM-powered classifier for intent chunks. @@ -23,8 +30,9 @@ def classify_intent_chunk(chunk: IntentChunk, llm_config: Optional[dict] = None) Returns: Classification result with action to take """ - chunk_text = chunk["text"] if isinstance( - chunk, dict) and "text" in chunk else str(chunk) + chunk_text = ( + chunk["text"] if isinstance(chunk, dict) and "text" in chunk else str(chunk) + ) # Fallback for empty chunks if not chunk_text.strip(): @@ -33,7 +41,7 @@ def classify_intent_chunk(chunk: IntentChunk, llm_config: Optional[dict] = None) "classification": IntentClassification.INVALID, "intent_type": None, "action": IntentAction.REJECT, - "metadata": {"confidence": 0.0, "reason": "Empty chunk"} + "metadata": {"confidence": 0.0, "reason": "Empty chunk"}, } # If no LLM config provided, use fallback logic @@ -55,12 +63,14 @@ def classify_intent_chunk(chunk: IntentChunk, llm_config: Optional[dict] = None) else: # Fallback if LLM parsing fails logger.warning( - f"LLM classification parsing failed, using fallback for: {chunk_text}") + f"LLM classification parsing failed, using fallback for: {chunk_text}" + ) return _fallback_classify(chunk_text) except Exception as e: logger.error( - f"LLM classification failed: {e}, using fallback for: {chunk_text}") + f"LLM classification failed: {e}, using fallback for: {chunk_text}" + ) return _fallback_classify(chunk_text) @@ -100,7 +110,10 @@ def _parse_classification_response(response: str, chunk_text: str) -> Classifier parsed = extract_json_from_text(response) if parsed: # Validate required fields - if all(key in parsed for key in ["classification", "action", "confidence", "reason"]): + if all( + key in parsed + for key in ["classification", "action", "confidence", "reason"] + ): return { "chunk_text": chunk_text, "classification": IntentClassification(parsed["classification"]), @@ -108,8 +121,8 @@ def _parse_classification_response(response: str, chunk_text: str) -> Classifier "action": IntentAction(parsed["action"]), "metadata": { "confidence": float(parsed["confidence"]), - "reason": str(parsed["reason"]) - } + "reason": str(parsed["reason"]), + }, } # If JSON parsing fails, try manual parsing return _manual_parse_classification(response, chunk_text) @@ -133,10 +146,7 @@ def _manual_parse_classification(response: str, chunk_text: str) -> ClassifierOu "classification": IntentClassification(classification), "intent_type": intent_type, "action": IntentAction(action), - "metadata": { - "confidence": float(confidence), - "reason": str(reason) - } + "metadata": {"confidence": float(confidence), "reason": str(reason)}, } response_lower = response.lower() @@ -147,7 +157,7 @@ def _manual_parse_classification(response: str, chunk_text: str) -> ClassifierOu "classification": IntentClassification.ATOMIC, "intent_type": "ExampleIntentType", "action": IntentAction.HANDLE, - "metadata": {"confidence": 0.7, "reason": "Manually parsed as atomic"} + "metadata": {"confidence": 0.7, "reason": "Manually parsed as atomic"}, } elif "composite" in response_lower or "split" in response_lower: return { @@ -155,7 +165,7 @@ def _manual_parse_classification(response: str, chunk_text: str) -> ClassifierOu "classification": IntentClassification.COMPOSITE, "intent_type": None, "action": IntentAction.SPLIT, - "metadata": {"confidence": 0.7, "reason": "Manually parsed as composite"} + "metadata": {"confidence": 0.7, "reason": "Manually parsed as composite"}, } elif "ambiguous" in response_lower or "clarify" in response_lower: return { @@ -163,7 +173,7 @@ def _manual_parse_classification(response: str, chunk_text: str) -> ClassifierOu "classification": IntentClassification.AMBIGUOUS, "intent_type": None, "action": IntentAction.CLARIFY, - "metadata": {"confidence": 0.5, "reason": "Manually parsed as ambiguous"} + "metadata": {"confidence": 0.5, "reason": "Manually parsed as ambiguous"}, } else: return { @@ -171,7 +181,7 @@ def _manual_parse_classification(response: str, chunk_text: str) -> ClassifierOu "classification": IntentClassification.INVALID, "intent_type": None, "action": IntentAction.REJECT, - "metadata": {"confidence": 0.3, "reason": "Manually parsed as invalid"} + "metadata": {"confidence": 0.3, "reason": "Manually parsed as invalid"}, } @@ -184,22 +194,11 @@ def _fallback_classify(chunk_text: str) -> ClassifierOutput: "classification": IntentClassification.AMBIGUOUS, "intent_type": None, "action": IntentAction.CLARIFY, - "metadata": {"confidence": 0.4, "reason": "Too short to classify"} + "metadata": {"confidence": 0.4, "reason": "Too short to classify"}, } - # Only split on clear multi-intent patterns - multi_intent_patterns = [ - r'\band\b.*\band\b', # Multiple "and"s - r'[^,]*,[^,]*,[^,]*', # Multiple commas - r'first.*second', # Ordinal patterns - r'one.*two', # Number patterns - r'\band\b', # Single "and" between distinct actions - r'\bplus\b', # "plus" conjunction - r'\balso\b', # "also" conjunction - ] - # Check for single conjunctions that likely indicate multiple intents - single_conjunctions = [r'\band\b', r'\bplus\b', r'\balso\b'] + single_conjunctions = [r"\band\b", r"\bplus\b", r"\balso\b"] for pattern in single_conjunctions: if re.search(pattern, chunk_text, re.IGNORECASE): # Check if the parts around the conjunction look like separate actions @@ -207,15 +206,27 @@ def _fallback_classify(chunk_text: str) -> ClassifierOutput: if len(parts) == 2: part1, part2 = parts[0].strip(), parts[1].strip() # If both parts have action verbs, likely composite - action_verbs = ['cancel', 'book', 'update', - 'get', 'show', 'calculate', 'greet'] - if any(verb in part1.lower() for verb in action_verbs) and any(verb in part2.lower() for verb in action_verbs): + action_verbs = [ + "cancel", + "book", + "update", + "get", + "show", + "calculate", + "greet", + ] + if any(verb in part1.lower() for verb in action_verbs) and any( + verb in part2.lower() for verb in action_verbs + ): return { "chunk_text": chunk_text, "classification": IntentClassification.COMPOSITE, "intent_type": None, "action": IntentAction.SPLIT, - "metadata": {"confidence": 0.8, "reason": f"Detected multi-intent pattern with conjunction: {pattern}"} + "metadata": { + "confidence": 0.8, + "reason": f"Detected multi-intent pattern with conjunction: {pattern}", + }, } # Default to atomic @@ -224,5 +235,5 @@ def _fallback_classify(chunk_text: str) -> ClassifierOutput: "classification": IntentClassification.ATOMIC, "intent_type": "ExampleIntentType", "action": IntentAction.HANDLE, - "metadata": {"confidence": 0.9, "reason": "Single clear intent detected"} + "metadata": {"confidence": 0.9, "reason": "Single clear intent detected"}, } diff --git a/intent_kit/classifiers/keyword.py b/intent_kit/classifiers/keyword.py index eb3725b..8617e91 100644 --- a/intent_kit/classifiers/keyword.py +++ b/intent_kit/classifiers/keyword.py @@ -4,7 +4,9 @@ from ..node import TreeNode -def keyword_classifier(user_input: str, children: list[TreeNode], context: Optional[Dict[str, Any]] = None) -> Optional[TreeNode]: +def keyword_classifier( + user_input: str, children: list[TreeNode], context: Optional[Dict[str, Any]] = None +) -> Optional[TreeNode]: """ A simple classifier that selects the first child whose name appears in the user input. diff --git a/intent_kit/classifiers/llm_classifier.py b/intent_kit/classifiers/llm_classifier.py index 28527f4..b02a6e1 100644 --- a/intent_kit/classifiers/llm_classifier.py +++ b/intent_kit/classifiers/llm_classifier.py @@ -15,9 +15,7 @@ def create_llm_classifier( - llm_config: Dict[str, Any], - classification_prompt: str, - node_descriptions: List[str] + llm_config: Dict[str, Any], classification_prompt: str, node_descriptions: List[str] ) -> Callable[[str, List[TreeNode], Optional[Dict[str, Any]]], Optional[TreeNode]]: """ Create an LLM-powered classifier function. @@ -30,7 +28,12 @@ def create_llm_classifier( Returns: Classifier function that can be used with ClassifierNode """ - def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[Dict[str, Any]] = None) -> Optional[TreeNode]: + + def llm_classifier( + user_input: str, + children: List[TreeNode], + context: Optional[Dict[str, Any]] = None, + ) -> Optional[TreeNode]: """ LLM-powered classifier that selects the most appropriate child node. @@ -54,12 +57,14 @@ def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[ # Build the classification prompt prompt = classification_prompt.format( user_input=user_input, - node_descriptions="\n".join([ - f"{i+1}. {child.name}: {child.description}" - for i, child in enumerate(children) - ]), + node_descriptions="\n".join( + [ + f"{i+1}. {child.name}: {child.description}" + for i, child in enumerate(children) + ] + ), num_nodes=len(children), - context_info=context_info + context_info=context_info, ) # Get LLM response @@ -75,24 +80,25 @@ def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[ # Make patterns more specific to avoid matching context numbers like years/timestamps number_patterns = [ # Match 1-2 digit numbers after "choice" - r'choice.*?(\d{1,2})', + r"choice.*?(\d{1,2})", # Match 1-2 digit numbers after "answer" - r'answer.*?(\d{1,2})', + r"answer.*?(\d{1,2})", # Match 1-2 digit numbers after "number" - r'number.*?(\d{1,2})', + r"number.*?(\d{1,2})", # Match 1-2 digit numbers after "select" - r'select.*?(\d{1,2})', + r"select.*?(\d{1,2})", # Match 1-2 digit numbers after "option" - r'option.*?(\d{1,2})', - r'^(\d{1,2})$', # Match standalone 1-2 digit numbers + r"option.*?(\d{1,2})", + r"^(\d{1,2})$", # Match standalone 1-2 digit numbers # Match 1-2 digit numbers with optional whitespace - r'^(\d{1,2})\s*$', + r"^(\d{1,2})\s*$", ] selected_index = None for pattern in number_patterns: - match = re.search(pattern, response_text, - re.IGNORECASE | re.MULTILINE) + match = re.search( + pattern, response_text, re.IGNORECASE | re.MULTILINE + ) if match: # Convert to 0-based selected_index = int(match.group(1)) - 1 @@ -101,7 +107,7 @@ def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[ # If no pattern matched, try to parse the entire response as a number if selected_index is None: # Clean up the response text - remove markdown formatting, asterisks, etc. - cleaned_text = re.sub(r'[^\d]', '', response_text) + cleaned_text = re.sub(r"[^\d]", "", response_text) if cleaned_text: # Only take the first 1-2 digits to avoid long numbers like years first_digits = cleaned_text[:2] @@ -113,14 +119,17 @@ def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[ return children[selected_index] else: logger.warning( - f"LLM returned invalid index: {selected_index} (valid range: 0-{len(children)-1})") + f"LLM returned invalid index: {selected_index} (valid range: 0-{len(children)-1})" + ) logger.warning( - f"Available children: {[child.name for child in children]}") + f"Available children: {[child.name for child in children]}" + ) logger.warning(f"Raw LLM response: {response_text}") return None except ValueError as e: logger.warning( - f"LLM response could not be parsed as integer: {response}") + f"LLM response could not be parsed as integer: {response}" + ) logger.warning(f"Parse error: {e}") return None @@ -132,9 +141,7 @@ def llm_classifier(user_input: str, children: List[TreeNode], context: Optional[ def create_llm_arg_extractor( - llm_config: Dict[str, Any], - extraction_prompt: str, - param_schema: Dict[str, Any] + llm_config: Dict[str, Any], extraction_prompt: str, param_schema: Dict[str, Any] ) -> Callable[[str, Optional[Dict[str, Any]]], Dict[str, Any]]: """ Create an LLM-powered argument extractor function. @@ -147,7 +154,10 @@ def create_llm_arg_extractor( Returns: Argument extractor function that can be used with HandlerNode """ - def llm_arg_extractor(user_input: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + + def llm_arg_extractor( + user_input: str, context: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """ LLM-powered argument extractor that extracts parameters from user input. @@ -168,16 +178,18 @@ def llm_arg_extractor(user_input: str, context: Optional[Dict[str, Any]] = None) context_info += "\nUse this context information to help extract more accurate parameters." # Build the extraction prompt - param_descriptions = "\n".join([ - f"- {param_name}: {param_type.__name__}" - for param_name, param_type in param_schema.items() - ]) + param_descriptions = "\n".join( + [ + f"- {param_name}: {param_type.__name__}" + for param_name, param_type in param_schema.items() + ] + ) prompt = extraction_prompt.format( user_input=user_input, param_descriptions=param_descriptions, param_names=", ".join(param_schema.keys()), - context_info=context_info + context_info=context_info, ) # Get LLM response @@ -194,11 +206,11 @@ def llm_arg_extractor(user_input: str, context: Optional[Dict[str, Any]] = None) extracted_params = {} # Simple parsing: look for "param_name: value" patterns - lines = response.strip().split('\n') + lines = response.strip().split("\n") for line in lines: line = line.strip() - if ':' in line: - key, value = line.split(':', 1) + if ":" in line: + key, value = line.split(":", 1) key = key.strip() value = value.strip() if key in param_schema: diff --git a/intent_kit/classifiers/node.py b/intent_kit/classifiers/node.py index 4b8dd3e..f6a89e2 100644 --- a/intent_kit/classifiers/node.py +++ b/intent_kit/classifiers/node.py @@ -1,10 +1,12 @@ from typing import Any, Callable, List, Optional, Dict, Union from intent_kit.node.base import TreeNode from intent_kit.node.enums import NodeType -from intent_kit.utils.logger import Logger from intent_kit.context import IntentContext from intent_kit.node.types import ExecutionResult, ExecutionError -from intent_kit.handlers.remediation import get_remediation_strategy, RemediationStrategy +from intent_kit.handlers.remediation import ( + get_remediation_strategy, + RemediationStrategy, +) class ClassifierNode(TreeNode): @@ -13,14 +15,17 @@ class ClassifierNode(TreeNode): def __init__( self, name: Optional[str], - classifier: Callable[[str, List["TreeNode"], Optional[Dict[str, Any]]], Optional["TreeNode"]], + classifier: Callable[ + [str, List["TreeNode"], Optional[Dict[str, Any]]], Optional["TreeNode"] + ], children: List["TreeNode"], description: str = "", parent: Optional["TreeNode"] = None, - remediation_strategies: Optional[List[Union[str, - RemediationStrategy]]] = None + remediation_strategies: Optional[List[Union[str, RemediationStrategy]]] = None, ): - super().__init__(name=name, description=description, children=children, parent=parent) + super().__init__( + name=name, description=description, children=children, parent=parent + ) self.classifier = classifier self.remediation_strategies = remediation_strategies or [] @@ -29,30 +34,27 @@ def node_type(self) -> NodeType: """Get the type of this node.""" return NodeType.CLASSIFIER - def execute(self, user_input: str, context: Optional[IntentContext] = None) -> ExecutionResult: - context_dict = None - if context: - # Only read context fields that are actually needed by the classifier - # For now, we'll pass an empty dict since most classifiers don't need context - # In the future, we could add context_inputs to ClassifierNode - context_dict = {} + def execute( + self, user_input: str, context: Optional[IntentContext] = None + ) -> ExecutionResult: + context_dict: Dict[str, Any] = {} + # If context is needed, populate context_dict here in the future chosen = self.classifier(user_input, self.children, context_dict) if not chosen: self.logger.error( - f"Classifier at '{self.name}' (Path: {'.'.join(self.get_path())}) could not route input.") + f"Classifier at '{self.name}' (Path: {'.'.join(self.get_path())}) could not route input." + ) # Try remediation strategies error = ExecutionError( error_type="ClassifierRoutingError", message=f"Classifier at '{self.name}' could not route input.", node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ) remediation_result = self._execute_remediation_strategies( - user_input=user_input, - context=context, - original_error=error + user_input=user_input, context=context, original_error=error ) if remediation_result: @@ -68,10 +70,11 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E output=None, error=error, params=None, - children_results=[] + children_results=[], ) self.logger.debug( - f"Classifier at '{self.name}' routed input to '{chosen.name}'.") + f"Classifier at '{self.name}' routed input to '{chosen.name}'." + ) child_result = chosen.execute(user_input, context) return ExecutionResult( success=True, @@ -83,16 +86,16 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error=None, params={ "chosen_child": chosen.name, - "available_children": [child.name for child in self.children] + "available_children": [child.name for child in self.children], }, - children_results=[child_result] + children_results=[child_result], ) def _execute_remediation_strategies( self, user_input: str, context: Optional[IntentContext] = None, - original_error: Optional[ExecutionError] = None + original_error: Optional[ExecutionError] = None, ) -> Optional[ExecutionResult]: """Execute remediation strategies for classifier failures.""" if not self.remediation_strategies: @@ -106,14 +109,16 @@ def _execute_remediation_strategies( strategy = get_remediation_strategy(strategy_item) if not strategy: self.logger.warning( - f"Remediation strategy '{strategy_item}' not found in registry") + f"Remediation strategy '{strategy_item}' not found in registry" + ) continue elif isinstance(strategy_item, RemediationStrategy): # Direct strategy object strategy = strategy_item else: self.logger.warning( - f"Invalid remediation strategy type: {type(strategy_item)}") + f"Invalid remediation strategy type: {type(strategy_item)}" + ) continue try: @@ -123,18 +128,21 @@ def _execute_remediation_strategies( context=context, original_error=original_error, classifier_func=self.classifier, - available_children=self.children + available_children=self.children, ) if result and result.success: self.logger.info( - f"Remediation strategy '{strategy.name}' succeeded for {self.name}") + f"Remediation strategy '{strategy.name}' succeeded for {self.name}" + ) return result else: self.logger.warning( - f"Remediation strategy '{strategy.name}' failed for {self.name}") + f"Remediation strategy '{strategy.name}' failed for {self.name}" + ) except Exception as e: self.logger.error( - f"Remediation strategy '{strategy.name}' error for {self.name}: {type(e).__name__}: {str(e)}") + f"Remediation strategy '{strategy.name}' error for {self.name}: {type(e).__name__}: {str(e)}" + ) self.logger.error(f"All remediation strategies failed for {self.name}") return None diff --git a/intent_kit/context/__init__.py b/intent_kit/context/__init__.py index 20ccfad..2bc5280 100644 --- a/intent_kit/context/__init__.py +++ b/intent_kit/context/__init__.py @@ -17,6 +17,7 @@ @dataclass class ContextField: """A lockable field in the context with metadata tracking.""" + value: Any lock: Lock = field(default_factory=Lock) last_modified: datetime = field(default_factory=datetime.now) @@ -27,6 +28,7 @@ class ContextField: @dataclass class ContextHistoryEntry: """An entry in the context history log.""" + timestamp: datetime action: str # 'set', 'get', 'delete' key: str @@ -38,6 +40,7 @@ class ContextHistoryEntry: @dataclass class ContextErrorEntry: """An error entry in the context error log.""" + timestamp: datetime node_name: str user_input: str @@ -78,7 +81,8 @@ def __init__(self, session_id: Optional[str] = None, debug: bool = False): if self._debug: self.logger.info( - f"Created IntentContext with session_id: {self.session_id}") + f"Created IntentContext with session_id: {self.session_id}" + ) def get(self, key: str, default: Any = None) -> Any: """ @@ -95,7 +99,8 @@ def get(self, key: str, default: Any = None) -> Any: if key not in self._fields: if self._debug: self.logger.debug( - f"Key '{key}' not found, returning default: {default}") + f"Key '{key}' not found, returning default: {default}" + ) self._log_history("get", key, default, None) return default field = self._fields[key] @@ -132,7 +137,8 @@ def set(self, key: str, value: Any, modified_by: Optional[str] = None) -> None: field.modified_by = modified_by if self._debug: self.logger.debug( - f"Updated field '{key}' from {old_value} to {value}") + f"Updated field '{key}' from {old_value} to {value}" + ) self._log_history("set", key, value, modified_by) @@ -150,8 +156,7 @@ def delete(self, key: str, modified_by: Optional[str] = None) -> bool: with self._global_lock: if key not in self._fields: if self._debug: - self.logger.debug( - f"Attempted to delete non-existent key '{key}'") + self.logger.debug(f"Attempted to delete non-existent key '{key}'") self._log_history("delete", key, None, modified_by) return False @@ -184,7 +189,9 @@ def keys(self) -> Set[str]: with self._global_lock: return set(self._fields.keys()) - def get_history(self, key: Optional[str] = None, limit: Optional[int] = None) -> List[ContextHistoryEntry]: + def get_history( + self, key: Optional[str] = None, limit: Optional[int] = None + ) -> List[ContextHistoryEntry]: """ Get the history of context operations. @@ -198,7 +205,8 @@ def get_history(self, key: Optional[str] = None, limit: Optional[int] = None) -> with self._global_lock: if key: filtered_history = [ - entry for entry in self._history if entry.key == key] + entry for entry in self._history if entry.key == key + ] else: filtered_history = self._history.copy() @@ -226,7 +234,7 @@ def get_field_metadata(self, key: str) -> Optional[Dict[str, Any]]: "created_at": field.created_at, "last_modified": field.last_modified, "modified_by": field.modified_by, - "value": field.value + "value": field.value, } def clear(self, modified_by: Optional[str] = None) -> None: @@ -243,7 +251,9 @@ def clear(self, modified_by: Optional[str] = None) -> None: self.logger.debug(f"Cleared all fields: {keys}") self._log_history("clear", "ALL", None, modified_by) - def _log_history(self, action: str, key: str, value: Any, modified_by: Optional[str]) -> None: + def _log_history( + self, action: str, key: str, value: Any, modified_by: Optional[str] + ) -> None: """Log an operation to the history.""" entry = ContextHistoryEntry( timestamp=datetime.now(), @@ -251,12 +261,18 @@ def _log_history(self, action: str, key: str, value: Any, modified_by: Optional[ key=key, value=value, modified_by=modified_by, - session_id=self.session_id + session_id=self.session_id, ) self._history.append(entry) - def add_error(self, node_name: str, user_input: str, - error_message: str, error_type: str, params: Optional[Dict[str, Any]] = None) -> None: + def add_error( + self, + node_name: str, + user_input: str, + error_message: str, + error_type: str, + params: Optional[Dict[str, Any]] = None, + ) -> None: """ Add an error to the context error log. @@ -276,16 +292,18 @@ def add_error(self, node_name: str, user_input: str, error_type=error_type, stack_trace=traceback.format_exc(), params=params, - session_id=self.session_id + session_id=self.session_id, ) self._errors.append(error_entry) if self._debug: self.logger.error( - f"Added error to context: {node_name}: {error_message}") + f"Added error to context: {node_name}: {error_message}" + ) - def get_errors(self, node_name: Optional[str] = None, - limit: Optional[int] = None) -> List[ContextErrorEntry]: + def get_errors( + self, node_name: Optional[str] = None, limit: Optional[int] = None + ) -> List[ContextErrorEntry]: """ Get errors from the context error log. @@ -301,7 +319,8 @@ def get_errors(self, node_name: Optional[str] = None, if node_name: filtered_errors = [ - error for error in filtered_errors if error.node_name == node_name] + error for error in filtered_errors if error.node_name == node_name + ] if limit: filtered_errors = filtered_errors[-limit:] diff --git a/intent_kit/context/debug.py b/intent_kit/context/debug.py index 12f74ea..3cde8ad 100644 --- a/intent_kit/context/debug.py +++ b/intent_kit/context/debug.py @@ -6,13 +6,14 @@ generating debug output, and visualizing context flow. """ -from typing import Dict, Any, Optional, List, Set, Tuple +from typing import Dict, Any, Optional, List, cast from datetime import datetime import json from . import IntentContext from .dependencies import ContextDependencies, analyze_handler_dependencies from intent_kit.node import TreeNode from intent_kit.utils.logger import Logger +from . import ContextHistoryEntry logger = Logger(__name__) @@ -45,30 +46,25 @@ def get_context_dependencies(graph: Any) -> Dict[str, ContextDependencies]: def validate_context_flow(graph: Any, context: IntentContext) -> Dict[str, Any]: """ - Check for missing dependencies and validate context flow. - - Args: - graph: IntentGraph instance to validate - context: Context object to validate against - - Returns: - Dictionary with validation results + Validate the context flow for a graph and context. """ dependencies = get_context_dependencies(graph) - validation_results = { + validation_results: Dict[str, Any] = { "valid": True, "missing_dependencies": {}, "available_fields": set(context.keys()), "total_nodes": len(dependencies), "nodes_with_dependencies": 0, - "warnings": [] + "warnings": [], } for node_name, deps in dependencies.items(): validation = _validate_node_dependencies(deps, context) if not validation["valid"]: validation_results["valid"] = False - validation_results["missing_dependencies"][node_name] = validation["missing_inputs"] + validation_results["missing_dependencies"][node_name] = validation[ + "missing_inputs" + ] if deps.inputs or deps.outputs: validation_results["nodes_with_dependencies"] += 1 @@ -76,8 +72,9 @@ def validate_context_flow(graph: Any, context: IntentContext) -> Dict[str, Any]: return validation_results -def trace_context_execution(graph: Any, user_input: str, context: IntentContext, - output_format: str = "console") -> str: +def trace_context_execution( + graph: Any, user_input: str, context: IntentContext, output_format: str = "console" +) -> str: """ Generate a detailed execution trace with context state changes. @@ -91,23 +88,38 @@ def trace_context_execution(graph: Any, user_input: str, context: IntentContext, Formatted execution trace """ # Capture history BEFORE we start reading context to avoid feedback loop - history_before_debug = context.get_history() + history_before_debug: List[ContextHistoryEntry] = context.get_history() # Capture context state without adding to history context_state = _capture_full_context_state(context) # Analyze history to get operation counts - set_ops = sum(1 for entry in history_before_debug if entry.action == "set") - get_ops = sum(1 for entry in history_before_debug if entry.action == "get") + set_ops = sum( + 1 + for entry in history_before_debug + if hasattr(entry, "action") and entry.action == "set" + ) + get_ops = sum( + 1 + for entry in history_before_debug + if hasattr(entry, "action") and entry.action == "get" + ) delete_ops = sum( - 1 for entry in history_before_debug if entry.action == "delete") - - context_state["history_summary"].update({ - "total_entries": len(history_before_debug), - "set_operations": set_ops, - "get_operations": get_ops, - "delete_operations": delete_ops - }) + 1 + for entry in history_before_debug + if hasattr(entry, "action") and entry.action == "delete" + ) + + # Cast to satisfy mypy + cast_dict = cast(Dict[str, Any], context_state["history_summary"]) + cast_dict.update( + { + "total_entries": len(history_before_debug), + "set_operations": set_ops, + "get_operations": get_ops, + "delete_operations": delete_ops, + } + ) trace_data = { "timestamp": datetime.now().isoformat(), @@ -116,10 +128,10 @@ def trace_context_execution(graph: Any, user_input: str, context: IntentContext, "execution_summary": { "total_fields": len(context.keys()), "history_entries": len(history_before_debug), - "error_count": context.error_count() + "error_count": context.error_count(), }, "context_state": context_state, - "history": _format_context_history(history_before_debug) + "history": _format_context_history(history_before_debug), } if output_format == "json": @@ -160,36 +172,36 @@ def _analyze_node_dependencies(node: TreeNode) -> Optional[ContextDependencies]: ContextDependencies if analysis is possible, None otherwise """ # Check if node has explicit dependencies - if hasattr(node, 'context_inputs') and hasattr(node, 'context_outputs'): - inputs = getattr(node, 'context_inputs', set()) - outputs = getattr(node, 'context_outputs', set()) + if hasattr(node, "context_inputs") and hasattr(node, "context_outputs"): + inputs: set = getattr(node, "context_inputs", set()) + outputs: set = getattr(node, "context_outputs", set()) return ContextDependencies( - inputs=inputs, - outputs=outputs, - description=f"Dependencies for {node.name}" + inputs=inputs, outputs=outputs, description=f"Dependencies for {node.name}" ) # Check if node has a handler function (HandlerNode) - if hasattr(node, 'handler'): - handler = getattr(node, 'handler') + if hasattr(node, "handler"): + handler = getattr(node, "handler") if callable(handler): return analyze_handler_dependencies(handler) # Check if node has a classifier function (ClassifierNode) - if hasattr(node, 'classifier'): - classifier = getattr(node, 'classifier') + if hasattr(node, "classifier"): + classifier = getattr(node, "classifier") if callable(classifier): # Classifiers typically don't modify context, but they might read from it return ContextDependencies( inputs=set(), outputs=set(), - description=f"Classifier {node.name} (no context dependencies detected)" + description=f"Classifier {node.name} (no context dependencies detected)", ) return None -def _validate_node_dependencies(deps: ContextDependencies, context: IntentContext) -> Dict[str, Any]: +def _validate_node_dependencies( + deps: ContextDependencies, context: IntentContext +) -> Dict[str, Any]: """ Validate dependencies for a specific node against a context. @@ -207,7 +219,7 @@ def _validate_node_dependencies(deps: ContextDependencies, context: IntentContex "valid": len(missing_inputs) == 0, "missing_inputs": missing_inputs, "available_inputs": deps.inputs & available_fields, - "outputs": deps.outputs + "outputs": deps.outputs, } @@ -221,7 +233,7 @@ def _capture_full_context_state(context: IntentContext) -> Dict[str, Any]: Returns: Dictionary with complete context state """ - state = { + state: Dict[str, Any] = { "session_id": context.session_id, "field_count": len(context.keys()), "fields": {}, @@ -229,13 +241,11 @@ def _capture_full_context_state(context: IntentContext) -> Dict[str, Any]: "total_entries": 0, # Will be set by caller "set_operations": 0, "get_operations": 0, - "delete_operations": 0 + "delete_operations": 0, }, - "error_summary": { - "total_errors": context.error_count(), - "recent_errors": [] - } + "error_summary": {"total_errors": context.error_count(), "recent_errors": []}, } + fields: Dict[str, Any] = state["fields"] # Capture all field values and metadata directly from internal state # to avoid adding GET operations to history @@ -246,12 +256,9 @@ def _capture_full_context_state(context: IntentContext) -> Dict[str, Any]: metadata = { "created_at": field.created_at.isoformat(), "last_modified": field.last_modified.isoformat(), - "modified_by": field.modified_by - } - state["fields"][key] = { - "value": value, - "metadata": metadata + "modified_by": field.modified_by, } + fields[key] = {"value": value, "metadata": metadata} # Get recent errors errors = context.get_errors(limit=5) @@ -260,7 +267,7 @@ def _capture_full_context_state(context: IntentContext) -> Dict[str, Any]: "timestamp": error.timestamp.isoformat(), "node_name": error.node_name, "error_message": error.error_message, - "error_type": error.error_type + "error_type": error.error_type, } for error in errors ] @@ -280,13 +287,15 @@ def _format_context_history(history: List[Any]) -> List[Dict[str, Any]]: """ formatted = [] for entry in history: - formatted.append({ - "timestamp": entry.timestamp.isoformat(), - "action": entry.action, - "key": entry.key, - "value": entry.value, - "modified_by": entry.modified_by - }) + formatted.append( + { + "timestamp": entry.timestamp.isoformat(), + "action": entry.action, + "key": entry.key, + "value": entry.value, + "modified_by": entry.modified_by, + } + ) return formatted @@ -304,23 +313,41 @@ def _format_console_trace(trace_data: Dict[str, Any]) -> str: lines.append(logger.colorize_separator("=" * 60)) lines.append(logger.colorize_section_title("CONTEXT EXECUTION TRACE")) lines.append(logger.colorize_separator("=" * 60)) - lines.append(logger.colorize_key_value( - "Timestamp", trace_data['timestamp'], "field_label", "timestamp")) - lines.append(logger.colorize_key_value( - "User Input", trace_data['user_input'], "field_label", "field_value")) - lines.append(logger.colorize_key_value( - "Session ID", trace_data['session_id'], "field_label", "timestamp")) + lines.append( + logger.colorize_key_value( + "Timestamp", trace_data["timestamp"], "field_label", "timestamp" + ) + ) + lines.append( + logger.colorize_key_value( + "User Input", trace_data["user_input"], "field_label", "field_value" + ) + ) + lines.append( + logger.colorize_key_value( + "Session ID", trace_data["session_id"], "field_label", "timestamp" + ) + ) lines.append("") # Execution summary summary = trace_data["execution_summary"] lines.append(logger.colorize_section_title("EXECUTION SUMMARY:")) - lines.append(logger.colorize_key_value(" Total Fields", - summary['total_fields'], "field_label", "timestamp")) - lines.append(logger.colorize_key_value(" History Entries", - summary['history_entries'], "field_label", "timestamp")) - lines.append(logger.colorize_key_value(" Error Count", - summary['error_count'], "field_label", "timestamp")) + lines.append( + logger.colorize_key_value( + " Total Fields", summary["total_fields"], "field_label", "timestamp" + ) + ) + lines.append( + logger.colorize_key_value( + " History Entries", summary["history_entries"], "field_label", "timestamp" + ) + ) + lines.append( + logger.colorize_key_value( + " Error Count", summary["error_count"], "field_label", "timestamp" + ) + ) lines.append("") # Context state @@ -332,30 +359,66 @@ def _format_console_trace(trace_data: Dict[str, Any]) -> str: # Format complex values more clearly if isinstance(value, list): - lines.append(logger.colorize_key_value( - f" {key}", f"(list with {len(value)} items)", "field_label", "timestamp")) + lines.append( + logger.colorize_key_value( + f" {key}", + f"(list with {len(value)} items)", + "field_label", + "timestamp", + ) + ) for i, item in enumerate(value): if isinstance(item, dict): - lines.append(logger.colorize_key_value( - f" [{i}]", dict(item), "field_label", "field_value")) + lines.append( + logger.colorize_key_value( + f" [{i}]", dict(item), "field_label", "field_value" + ) + ) else: - lines.append(logger.colorize_key_value( - f" [{i}]", item, "field_label", "field_value")) + lines.append( + logger.colorize_key_value( + f" [{i}]", item, "field_label", "field_value" + ) + ) elif isinstance(value, dict): - lines.append(logger.colorize_key_value( - f" {key}", f"(dict with {len(value)} items)", "field_label", "timestamp")) + lines.append( + logger.colorize_key_value( + f" {key}", + f"(dict with {len(value)} items)", + "field_label", + "timestamp", + ) + ) for k, v in value.items(): - lines.append(logger.colorize_key_value( - f" {k}", v, "field_label", "field_value")) + lines.append( + logger.colorize_key_value( + f" {k}", v, "field_label", "field_value" + ) + ) else: - lines.append(logger.colorize_key_value( - f" {key}", value, "field_label", "field_value")) + lines.append( + logger.colorize_key_value( + f" {key}", value, "field_label", "field_value" + ) + ) if metadata: - lines.append(logger.colorize_key_value(" Modified", metadata.get( - 'last_modified', 'Unknown'), "field_label", "timestamp")) - lines.append(logger.colorize_key_value( - " By", metadata.get('modified_by', 'Unknown'), "field_label", "timestamp")) + lines.append( + logger.colorize_key_value( + " Modified", + metadata.get("last_modified", "Unknown"), + "field_label", + "timestamp", + ) + ) + lines.append( + logger.colorize_key_value( + " By", + metadata.get("modified_by", "Unknown"), + "field_label", + "timestamp", + ) + ) lines.append("") # Recent history @@ -363,10 +426,10 @@ def _format_console_trace(trace_data: Dict[str, Any]) -> str: if history: lines.append(logger.colorize_section_title("RECENT HISTORY:")) for entry in history[-10:]: # Last 10 entries - timestamp = logger.colorize_timestamp(entry['timestamp']) - action = logger.colorize_action(entry['action'].upper()) - key = logger.colorize_field_label(entry['key']) - value = logger.colorize_field_value(str(entry['value'])) + timestamp = logger.colorize_timestamp(entry["timestamp"]) + action = logger.colorize_action(entry["action"].upper()) + key = logger.colorize_field_label(entry["key"]) + value = logger.colorize_field_value(str(entry["value"])) lines.append(f" [{timestamp}] {action}: {key} = {value}") lines.append("") @@ -375,9 +438,9 @@ def _format_console_trace(trace_data: Dict[str, Any]) -> str: if errors: lines.append(logger.colorize_section_title("RECENT ERRORS:")) for error in errors: - timestamp = logger.colorize_timestamp(error['timestamp']) - node_name = logger.colorize_error_soft(error['node_name']) - error_msg = logger.colorize_error_soft(error['error_message']) + timestamp = logger.colorize_timestamp(error["timestamp"]) + node_name = logger.colorize_error_soft(error["node_name"]) + error_msg = logger.colorize_error_soft(error["error_message"]) lines.append(f" [{timestamp}] {node_name}: {error_msg}") lines.append("") diff --git a/intent_kit/context/dependencies.py b/intent_kit/context/dependencies.py index c25e248..777ad31 100644 --- a/intent_kit/context/dependencies.py +++ b/intent_kit/context/dependencies.py @@ -13,6 +13,7 @@ @dataclass class ContextDependencies: """Declares what context fields an intent reads and writes.""" + inputs: Set[str] # Fields this intent reads from context outputs: Set[str] # Fields this intent writes to context description: str = "" # Human-readable description of dependencies @@ -31,7 +32,9 @@ def __call__(self, context: IntentContext, **kwargs) -> Any: ... -def declare_dependencies(inputs: Set[str], outputs: Set[str], description: str = "") -> ContextDependencies: +def declare_dependencies( + inputs: Set[str], outputs: Set[str], description: str = "" +) -> ContextDependencies: """ Create a context dependency declaration. @@ -43,17 +46,11 @@ def declare_dependencies(inputs: Set[str], outputs: Set[str], description: str = Returns: ContextDependencies object """ - return ContextDependencies( - inputs=inputs, - outputs=outputs, - description=description - ) + return ContextDependencies(inputs=inputs, outputs=outputs, description=description) def validate_context_dependencies( - dependencies: ContextDependencies, - context: IntentContext, - strict: bool = False + dependencies: ContextDependencies, context: IntentContext, strict: bool = False ) -> Dict[str, Any]: """ Validate that required context fields are available. @@ -71,22 +68,21 @@ def validate_context_dependencies( - warnings: List[str] """ available_fields = context.keys() - missing_inputs = dependencies.inputs - available_fields - available_inputs = dependencies.inputs & available_fields + missing_inputs: set = dependencies.inputs - available_fields + available_inputs: set = dependencies.inputs & available_fields warnings = [] if missing_inputs and strict: warnings.append(f"Missing required context inputs: {missing_inputs}") if missing_inputs and not strict: - warnings.append( - f"Optional context inputs not available: {missing_inputs}") + warnings.append(f"Optional context inputs not available: {missing_inputs}") return { "valid": len(missing_inputs) == 0 or not strict, "missing_inputs": missing_inputs, "available_inputs": available_inputs, - "warnings": warnings + "warnings": warnings, } @@ -103,9 +99,9 @@ def merge_dependencies(*dependencies: ContextDependencies) -> ContextDependencie if not dependencies: return declare_dependencies(set(), set(), "Empty dependencies") - merged_inputs = set() - merged_outputs = set() - descriptions = [] + merged_inputs: set = set() + merged_outputs: set = set() + descriptions: list = [] for dep in dependencies: merged_inputs.update(dep.inputs) @@ -119,7 +115,7 @@ def merge_dependencies(*dependencies: ContextDependencies) -> ContextDependencie return ContextDependencies( inputs=merged_inputs, outputs=merged_outputs, - description="; ".join(descriptions) if descriptions else "" + description="; ".join(descriptions) if descriptions else "", ) @@ -140,35 +136,37 @@ def analyze_handler_dependencies(handler: Any) -> Optional[ContextDependencies]: return None # Check if handler has explicit dependencies - if hasattr(handler, 'context_dependencies'): + if hasattr(handler, "context_dependencies"): return handler.context_dependencies # Check if handler has dependency annotations - if hasattr(handler, '__annotations__'): + if hasattr(handler, "__annotations__"): annotations = handler.__annotations__ - if 'context_inputs' in annotations and 'context_outputs' in annotations: - inputs = getattr(handler, 'context_inputs', set()) - outputs = getattr(handler, 'context_outputs', set()) + if "context_inputs" in annotations and "context_outputs" in annotations: + inputs: set = getattr(handler, "context_inputs", set()) + outputs: set = getattr(handler, "context_outputs", set()) return declare_dependencies(inputs, outputs) # Check docstring for dependency hints - if hasattr(handler, '__doc__') and handler.__doc__: + if hasattr(handler, "__doc__") and handler.__doc__: doc = handler.__doc__.lower() inputs = set() outputs = set() # Simple pattern matching for common phrases - if 'context' in doc: - if 'read' in doc or 'get' in doc: + if "context" in doc: + if "read" in doc or "get" in doc: # This is a heuristic - in practice, explicit declarations are better pass - if 'write' in doc or 'set' in doc or 'update' in doc: + if "write" in doc or "set" in doc or "update" in doc: pass return None -def create_dependency_graph(nodes: Dict[str, ContextDependencies]) -> Dict[str, Set[str]]: +def create_dependency_graph( + nodes: Dict[str, ContextDependencies], +) -> Dict[str, Set[str]]: """ Create a dependency graph from node dependencies. @@ -178,7 +176,7 @@ def create_dependency_graph(nodes: Dict[str, ContextDependencies]) -> Dict[str, Returns: Dict mapping node names to sets of dependent nodes """ - graph = {} + graph: Dict[str, Set[str]] = {} for node_name, deps in nodes.items(): graph[node_name] = set() diff --git a/intent_kit/evals/__init__.py b/intent_kit/evals/__init__.py index b719307..29d34e7 100644 --- a/intent_kit/evals/__init__.py +++ b/intent_kit/evals/__init__.py @@ -17,6 +17,7 @@ @dataclass class EvalTestCase: """A single test case with input, expected output, and optional context.""" + input: str expected: Any context: Dict[str, Any] @@ -29,6 +30,7 @@ def __post_init__(self): @dataclass class Dataset: """A dataset containing test cases for evaluating a node.""" + name: str description: str node_type: str @@ -43,6 +45,7 @@ def __post_init__(self): @dataclass class EvalTestResult: """Result of a single test case evaluation.""" + input: str expected: Any actual: Any @@ -85,11 +88,12 @@ def errors(self) -> List[EvalTestResult]: def print_summary(self) -> None: print(f"\nEvaluation Results for {self.dataset_name or 'Dataset'}:") print( - f" Accuracy: {self.accuracy():.1%} ({self.passed_count()}/{self.total_count()})") + f" Accuracy: {self.accuracy():.1%} ({self.passed_count()}/{self.total_count()})" + ) print(f" Passed: {self.passed_count()}") print(f" Failed: {self.failed_count()}") if self.errors(): - print(f"\nFailed Tests:") + print("\nFailed Tests:") for i, error in enumerate(self.errors()[:5]): print(f" {i+1}. Input: '{error.input}'") print(f" Expected: '{error.expected}'") @@ -105,21 +109,25 @@ def save_csv(self, path: Optional[str] = None) -> str: results_dir = Path(__file__).parent / "results" / "latest" results_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - path = str(results_dir / - f"{self.dataset_name}_eval_results_{timestamp}.csv") - with open(path, 'w', newline='', encoding='utf-8') as f: + path = str( + results_dir / f"{self.dataset_name}_eval_results_{timestamp}.csv" + ) + with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) - writer.writerow(['input', 'expected', 'actual', - 'passed', 'error', 'context']) + writer.writerow( + ["input", "expected", "actual", "passed", "error", "context"] + ) for result in self.results: - writer.writerow([ - result.input, - result.expected, - result.actual, - result.passed, - result.error or '', - str(result.context) - ]) + writer.writerow( + [ + result.input, + result.expected, + result.actual, + result.passed, + result.error or "", + str(result.context), + ] + ) return str(path) def save_json(self, path: Optional[str] = None) -> str: @@ -127,30 +135,32 @@ def save_json(self, path: Optional[str] = None) -> str: results_dir = Path(__file__).parent / "results" / "latest" results_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - path = str(results_dir / - f"{self.dataset_name}_eval_results_{timestamp}.json") + path = str( + results_dir / f"{self.dataset_name}_eval_results_{timestamp}.json" + ) import json + data = { - 'dataset_name': self.dataset_name, - 'summary': { - 'accuracy': self.accuracy(), - 'passed_count': self.passed_count(), - 'failed_count': self.failed_count(), - 'total_count': self.total_count() + "dataset_name": self.dataset_name, + "summary": { + "accuracy": self.accuracy(), + "passed_count": self.passed_count(), + "failed_count": self.failed_count(), + "total_count": self.total_count(), }, - 'results': [ + "results": [ { - 'input': r.input, - 'expected': r.expected, - 'actual': r.actual, - 'passed': r.passed, - 'error': r.error, - 'context': r.context + "input": r.input, + "expected": r.expected, + "actual": r.actual, + "passed": r.passed, + "error": r.error, + "context": r.context, } for r in self.results - ] + ], } - with open(path, 'w') as f: + with open(path, "w") as f: json.dump(data, f, indent=2) return str(path) @@ -159,8 +169,7 @@ def save_markdown(self, path: Optional[str] = None) -> str: reports_dir = Path(__file__).parent / "reports" / "latest" reports_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - path = str(reports_dir / - f"{self.dataset_name}_eval_report_{timestamp}.md") + path = str(reports_dir / f"{self.dataset_name}_eval_report_{timestamp}.md") report = f"""# Evaluation Report: {self.dataset_name} **Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} @@ -189,7 +198,7 @@ def save_markdown(self, path: Optional[str] = None) -> str: if error.error: report += f"- **Error:** {error.error}\n" report += "\n" - with open(path, 'w') as f: + with open(path, "w") as f: f.write(report) return str(path) @@ -198,37 +207,35 @@ def load_dataset(path: Union[str, Path]) -> Dataset: path = Path(path) if not path.exists(): raise FileNotFoundError(f"Dataset file not found: {path}") - with open(path, 'r') as f: + with open(path, "r") as f: data = yaml.safe_load(f) - if 'dataset' not in data: + if "dataset" not in data: raise ValueError(f"Dataset file missing 'dataset' section: {path}") - dataset_info = data['dataset'] - required_fields = ['name', 'node_type', 'node_name'] + dataset_info = data["dataset"] + required_fields = ["name", "node_type", "node_name"] for field in required_fields: if field not in dataset_info: - raise ValueError( - f"Dataset missing required field '{field}': {path}") - if 'test_cases' not in data: + raise ValueError(f"Dataset missing required field '{field}': {path}") + if "test_cases" not in data: raise ValueError(f"Dataset file missing 'test_cases' section: {path}") test_cases = [] - for i, tc_data in enumerate(data['test_cases']): - if 'input' not in tc_data: + for i, tc_data in enumerate(data["test_cases"]): + if "input" not in tc_data: raise ValueError(f"Test case {i+1} missing 'input' field: {path}") - if 'expected' not in tc_data: - raise ValueError( - f"Test case {i+1} missing 'expected' field: {path}") + if "expected" not in tc_data: + raise ValueError(f"Test case {i+1} missing 'expected' field: {path}") test_case = EvalTestCase( - input=tc_data['input'], - expected=tc_data['expected'], - context=tc_data.get('context', {}) + input=tc_data["input"], + expected=tc_data["expected"], + context=tc_data.get("context", {}), ) test_cases.append(test_case) return Dataset( - name=dataset_info['name'], - description=dataset_info.get('description', ''), - node_type=dataset_info['node_type'], - node_name=dataset_info['node_name'], - test_cases=test_cases + name=dataset_info["name"], + description=dataset_info.get("description", ""), + node_type=dataset_info["node_type"], + node_name=dataset_info["node_name"], + test_cases=test_cases, ) @@ -245,19 +252,22 @@ def run_eval( dataset: Dataset, node: Any, comparator: Optional[Callable[[Any, Any], bool]] = None, - fail_fast: bool = False + fail_fast: bool = False, ) -> EvalResult: if comparator is None: + def default_comparator(expected, actual): return expected == actual + comparator = default_comparator results = [] for test_case in dataset.test_cases: try: if callable(node): actual = node(test_case.input, context=test_case.context) - elif hasattr(node, 'execute'): + elif hasattr(node, "execute"): from intent_kit.context import IntentContext + context = IntentContext() for key, value in test_case.context.items(): context.set(key, value, modified_by="eval") @@ -266,15 +276,14 @@ def default_comparator(expected, actual): if not result.success and result.error: raise Exception(result.error.message) else: - raise ValueError( - "Node must be callable or have an .execute() method") + raise ValueError("Node must be callable or have an .execute() method") passed = comparator(test_case.expected, actual) result = EvalTestResult( input=test_case.input, expected=test_case.expected, actual=actual, passed=passed, - context=test_case.context + context=test_case.context, ) except Exception as e: result = EvalTestResult( @@ -283,7 +292,7 @@ def default_comparator(expected, actual): actual=None, passed=False, context=test_case.context, - error=str(e) + error=str(e), ) if fail_fast: results.append(result) @@ -296,7 +305,7 @@ def run_eval_from_path( dataset_path: Union[str, Path], node: Any, comparator: Optional[Callable[[Any, Any], bool]] = None, - fail_fast: bool = False + fail_fast: bool = False, ) -> EvalResult: dataset = load_dataset(dataset_path) return run_eval(dataset, node, comparator, fail_fast) @@ -307,7 +316,7 @@ def run_eval_from_module( module_name: str, node_name: str, comparator: Optional[Callable[[Any, Any], bool]] = None, - fail_fast: bool = False + fail_fast: bool = False, ) -> EvalResult: dataset = load_dataset(dataset_path) node = get_node_from_module(module_name, node_name) @@ -326,5 +335,5 @@ def run_eval_from_module( "get_node_from_module", "run_eval", "run_eval_from_path", - "run_eval_from_module" + "run_eval_from_module", ] diff --git a/intent_kit/evals/run_all_evals.py b/intent_kit/evals/run_all_evals.py index 4aa20e4..7ad9359 100644 --- a/intent_kit/evals/run_all_evals.py +++ b/intent_kit/evals/run_all_evals.py @@ -5,13 +5,18 @@ Run evaluations on all datasets and generate comprehensive markdown reports. """ -from intent_kit.evals.run_node_eval import load_dataset, get_node_from_module, evaluate_node, generate_markdown_report +from intent_kit.evals.run_node_eval import ( + load_dataset, + get_node_from_module, + evaluate_node, + generate_markdown_report, +) import yaml from typing import Dict, List, Any, Optional from datetime import datetime -import sys import pathlib from dotenv import load_dotenv + load_dotenv() @@ -20,16 +25,24 @@ def run_all_evaluations(): import argparse parser = argparse.ArgumentParser( - description="Run all evaluations and generate comprehensive report") - parser.add_argument("--output", type=str, default="intent_kit/evals/reports/latest/comprehensive_report.md", - help="Output file for comprehensive report") - parser.add_argument("--individual", action="store_true", - help="Also generate individual reports for each dataset") - parser.add_argument("--quiet", action="store_true", - help="Suppress output messages") + description="Run all evaluations and generate comprehensive report" + ) + parser.add_argument( + "--output", + type=str, + default="intent_kit/evals/reports/latest/comprehensive_report.md", + help="Output file for comprehensive report", + ) + parser.add_argument( + "--individual", + action="store_true", + help="Also generate individual reports for each dataset", + ) + parser.add_argument("--quiet", action="store_true", help="Suppress output messages") parser.add_argument("--llm-config", help="Path to LLM configuration file") - parser.add_argument("--mock", action="store_true", - help="Run in mock mode without real API calls") + parser.add_argument( + "--mock", action="store_true", help="Run in mock mode without real API calls" + ) # Parse args if called as script, otherwise use defaults try: @@ -54,40 +67,49 @@ def run_all_evaluations(): if not args.quiet: mode = "MOCK" if args.mock else "LIVE" print(f"Running all evaluations in {mode} mode...") - results = run_all_evaluations_internal( - args.llm_config, mock_mode=args.mock) + results = run_all_evaluations_internal(args.llm_config, mock_mode=args.mock) if not args.quiet: print("Generating comprehensive report...") - report = generate_comprehensive_report( - results, str(output_path), run_timestamp=run_timestamp, mock_mode=args.mock) + generate_comprehensive_report( + results, str(output_path), run_timestamp=run_timestamp, mock_mode=args.mock + ) # Also write timestamped copy to date-based archive directory - date_comprehensive_report_path = date_reports_dir / \ - f"comprehensive_report_{run_timestamp}.md" - with open(output_path, 'r') as src, open(date_comprehensive_report_path, 'w') as dst: + date_comprehensive_report_path = ( + date_reports_dir / f"comprehensive_report_{run_timestamp}.md" + ) + with ( + open(output_path, "r") as src, + open(date_comprehensive_report_path, "w") as dst, + ): dst.write(src.read()) if not args.quiet: - print( - f"Comprehensive report archived as: {date_comprehensive_report_path}") + print(f"Comprehensive report archived as: {date_comprehensive_report_path}") if args.individual: if not args.quiet: print("Generating individual reports...") for result in results: - dataset_name = result['dataset'] + dataset_name = result["dataset"] individual_report_path = reports_dir / f"{dataset_name}_report.md" # Write to latest generate_markdown_report( - [result], individual_report_path, run_timestamp=run_timestamp) + [result], individual_report_path, run_timestamp=run_timestamp + ) # Also write to date-based archive with timestamp in filename - date_individual_report_path = date_reports_dir / \ - f"{dataset_name}_report_{run_timestamp}.md" - with open(individual_report_path, 'r') as src, open(date_individual_report_path, 'w') as dst: + date_individual_report_path = ( + date_reports_dir / f"{dataset_name}_report_{run_timestamp}.md" + ) + with ( + open(individual_report_path, "r") as src, + open(date_individual_report_path, "w") as dst, + ): dst.write(src.read()) if not args.quiet: print( - f"Individual report written to: {individual_report_path} and archived as {date_individual_report_path}") + f"Individual report written to: {individual_report_path} and archived as {date_individual_report_path}" + ) if not args.quiet: print("Evaluation complete!") @@ -95,7 +117,9 @@ def run_all_evaluations(): return True -def run_all_evaluations_internal(llm_config_path: Optional[str] = None, mock_mode: bool = False) -> List[Dict[str, Any]]: +def run_all_evaluations_internal( + llm_config_path: Optional[str] = None, mock_mode: bool = False +) -> List[Dict[str, Any]]: """Run evaluations on all datasets and return results.""" dataset_dir = pathlib.Path(__file__).parent / "datasets" results = [] @@ -103,7 +127,8 @@ def run_all_evaluations_internal(llm_config_path: Optional[str] = None, mock_mod # Load LLM configuration if provided if llm_config_path: import os - with open(llm_config_path, 'r') as f: + + with open(llm_config_path, "r") as f: llm_config = yaml.safe_load(f) # Set environment variables for API keys @@ -116,6 +141,7 @@ def run_all_evaluations_internal(llm_config_path: Optional[str] = None, mock_mod # Set mock mode environment variable if mock_mode: import os + os.environ["INTENT_KIT_MOCK_MODE"] = "1" print("Running in MOCK mode - using simulated responses") @@ -129,9 +155,13 @@ def run_all_evaluations_internal(llm_config_path: Optional[str] = None, mock_mod # Determine module name based on node name if "llm" in node_name: - module_name = f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node_llm" + module_name = ( + f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node_llm" + ) else: - module_name = f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node" + module_name = ( + f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node" + ) # Load node node = get_node_from_module(module_name, node_name) @@ -148,12 +178,18 @@ def run_all_evaluations_internal(llm_config_path: Optional[str] = None, mock_mod accuracy = result["accuracy"] mode_indicator = "[MOCK]" if mock_mode else "" print( - f" Accuracy: {accuracy:.1%} ({result['correct']}/{result['total_cases']}) {mode_indicator}") + f" Accuracy: {accuracy:.1%} ({result['correct']}/{result['total_cases']}) {mode_indicator}" + ) return results -def generate_comprehensive_report(results: List[Dict[str, Any]], output_file: Optional[str] = None, run_timestamp: str = "", mock_mode: bool = False) -> str: +def generate_comprehensive_report( + results: List[Dict[str, Any]], + output_file: Optional[str] = None, + run_timestamp: str = "", + mock_mode: bool = False, +) -> str: """Generate a comprehensive markdown report for all evaluations.""" total_datasets = len(results) @@ -162,8 +198,7 @@ def generate_comprehensive_report(results: List[Dict[str, Any]], output_file: Op overall_accuracy = total_passed / total_tests if total_tests > 0 else 0.0 # Count statuses - passed_datasets = sum( - 1 for r in results if r["accuracy"] >= 0.8) # 80% threshold + passed_datasets = sum(1 for r in results if r["accuracy"] >= 0.8) # 80% threshold failed_datasets = total_datasets - passed_datasets # Add mock mode indicator @@ -207,7 +242,9 @@ def generate_comprehensive_report(results: List[Dict[str, Any]], output_file: Op for result in results: report += f"### {result['dataset']}\n\n" report += f"**Accuracy:** {result['accuracy']:.1%} ({result['correct']}/{result['total_cases']}) \n" - report += f"**Status:** {'PASSED' if result['accuracy'] >= 0.8 else 'FAILED'}\n\n" + report += ( + f"**Status:** {'PASSED' if result['accuracy'] >= 0.8 else 'FAILED'}\n\n" + ) # Show errors if any if result["errors"]: @@ -216,7 +253,7 @@ def generate_comprehensive_report(results: List[Dict[str, Any]], output_file: Op report += f"- **Case {error['case']}**: {error['input']}\n" report += f" - Expected: `{error['expected']}`\n" report += f" - Actual: `{error['actual']}`\n" - if error.get('error'): + if error.get("error"): report += f" - Error: {error['error']}\n" report += "\n" if len(result["errors"]) > 5: @@ -224,7 +261,7 @@ def generate_comprehensive_report(results: List[Dict[str, Any]], output_file: Op # Write to file if specified if output_file: - with open(output_file, 'w') as f: + with open(output_file, "w") as f: f.write(report) print(f"Comprehensive report written to: {output_file}") diff --git a/intent_kit/evals/run_node_eval.py b/intent_kit/evals/run_node_eval.py index f6dd9ea..9e072c6 100644 --- a/intent_kit/evals/run_node_eval.py +++ b/intent_kit/evals/run_node_eval.py @@ -5,7 +5,6 @@ Run evaluations on sample nodes using datasets. """ -from intent_kit.node.types import ExecutionResult from intent_kit.context import IntentContext from typing import Dict, Any, List, Optional from pathlib import Path @@ -15,10 +14,8 @@ import importlib import argparse from dotenv import load_dotenv -import json import csv from datetime import datetime -import uuid # Add text similarity imports from difflib import SequenceMatcher @@ -26,10 +23,12 @@ load_dotenv() +_first_test_case: dict = {} + def load_dataset(dataset_path: Path) -> Dict[str, Any]: """Load a dataset from YAML file.""" - with open(dataset_path, 'r') as f: + with open(dataset_path, "r") as f: return yaml.safe_load(f) @@ -43,7 +42,15 @@ def get_node_from_module(module_name: str, node_name: str): return None -def save_raw_results_to_csv(dataset_name: str, test_case: Dict[str, Any], actual_output: Any, success: bool, error: Optional[str] = None, similarity_score: Optional[float] = None, run_timestamp: Optional[str] = None): +def save_raw_results_to_csv( + dataset_name: str, + test_case: Dict[str, Any], + actual_output: Any, + success: bool, + error: Optional[str] = None, + similarity_score: Optional[float] = None, + run_timestamp: Optional[str] = None, +): """Save raw evaluation results to CSV files.""" # Create organized results directory structure today = datetime.now().strftime("%Y-%m-%d") @@ -64,24 +71,21 @@ def save_raw_results_to_csv(dataset_name: str, test_case: Dict[str, Any], actual # Prepare row data row_data = { - "timestamp": importlib.import_module('datetime').datetime.now().isoformat(), + "timestamp": importlib.import_module("datetime").datetime.now().isoformat(), "input": test_case["input"], "expected": test_case["expected"], "actual": actual_output, "success": success, "similarity_score": similarity_score or "", "error": error or "", - "context": str(test_case.get("context", {})) + "context": str(test_case.get("context", {})), } # Check if this is the first test case (to write header) global _first_test_case - if not hasattr(save_raw_results_to_csv, '_first_test_case'): - save_raw_results_to_csv._first_test_case = {} - - is_first = dataset_name not in save_raw_results_to_csv._first_test_case + is_first = dataset_name not in _first_test_case if is_first: - save_raw_results_to_csv._first_test_case[dataset_name] = True + _first_test_case[dataset_name] = True # Clear both files for new evaluation run if csv_file.exists(): csv_file.unlink() @@ -89,7 +93,7 @@ def save_raw_results_to_csv(dataset_name: str, test_case: Dict[str, Any], actual date_csv_file.unlink() # Write to latest directory - with open(csv_file, 'a', newline='', encoding='utf-8') as f: + with open(csv_file, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=row_data.keys()) if is_first: writer.writeheader() @@ -97,7 +101,7 @@ def save_raw_results_to_csv(dataset_name: str, test_case: Dict[str, Any], actual # Write to date-based directory for archiving (always write header for new file) write_header = not date_csv_file.exists() - with open(date_csv_file, 'a', newline='', encoding='utf-8') as f: + with open(date_csv_file, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=row_data.keys()) if write_header: writer.writeheader() @@ -108,9 +112,10 @@ def save_raw_results_to_csv(dataset_name: str, test_case: Dict[str, Any], actual def similarity_score(text1: str, text2: str) -> float: """Calculate similarity score between two texts.""" + # Normalize texts for comparison def normalize(text): - return re.sub(r'\s+', ' ', text.lower().strip()) + return re.sub(r"\s+", " ", text.lower().strip()) norm1 = normalize(text1) norm2 = normalize(text2) @@ -119,7 +124,9 @@ def normalize(text): return SequenceMatcher(None, norm1, norm2).ratio() -def chunks_similarity_score(expected_chunks: List[str], actual_chunks: List[str], threshold: float = 0.8) -> tuple[bool, float]: +def chunks_similarity_score( + expected_chunks: List[str], actual_chunks: List[str], threshold: float = 0.8 +) -> tuple[bool, float]: """Calculate similarity score between expected and actual chunks.""" if len(expected_chunks) != len(actual_chunks): return False, 0.0 @@ -133,32 +140,32 @@ def chunks_similarity_score(expected_chunks: List[str], actual_chunks: List[str] return avg_score >= threshold, avg_score -def evaluate_node(node, test_cases: List[Dict[str, Any]], dataset_name: str) -> Dict[str, Any]: +def evaluate_node( + node, test_cases: List[Dict[str, Any]], dataset_name: str +) -> Dict[str, Any]: """Evaluate a node against test cases.""" - results = { + results: Dict[str, Any] = { "dataset": dataset_name, "total_cases": len(test_cases), "correct": 0, "incorrect": 0, "errors": [], "details": [], - "raw_results_file": f"intent_kit/evals/results/latest/{dataset_name}_results.csv" + "raw_results_file": f"intent_kit/evals/results/latest/{dataset_name}_results.csv", } # Generate a unique run timestamp for this evaluation run_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") # Check if this node needs persistent context (like handler_node_llm) - needs_persistent_context = hasattr( - node, 'name') and 'handler_node_llm' in node.name + needs_persistent_context = hasattr(node, "name") and "handler_node_llm" in node.name # Create persistent context if needed persistent_context = None if needs_persistent_context: persistent_context = IntentContext() # Initialize booking count for handler_node_llm - persistent_context.set( - "booking_count", 0, modified_by="evaluation_init") + persistent_context.set("booking_count", 0, modified_by="evaluation_init") for i, test_case in enumerate(test_cases): user_input = test_case["input"] @@ -189,78 +196,117 @@ def evaluate_node(node, test_cases: List[Dict[str, Any]], dataset_name: str) -> # For splitters, compare lists using similarity if isinstance(expected, list): correct, similarity_score_val = chunks_similarity_score( - expected, actual_output) + expected, actual_output + ) else: correct = False else: # For handlers and classifiers, compare strings - correct = str(actual_output).strip().lower() == str( - expected).strip().lower() + correct = ( + str(actual_output).strip().lower() + == str(expected).strip().lower() + ) if correct: results["correct"] += 1 else: results["incorrect"] += 1 - results["errors"].append({ - "case": i + 1, - "input": user_input, - "expected": expected, - "actual": actual_output, - "similarity_score": similarity_score_val, - "type": "incorrect_output" - }) + results["errors"].append( + { + "case": i + 1, + "input": user_input, + "expected": expected, + "actual": actual_output, + "similarity_score": similarity_score_val, + "type": "incorrect_output", + } + ) # Save raw result to CSV save_raw_results_to_csv( - dataset_name, test_case, actual_output, correct, similarity_score=similarity_score_val, run_timestamp=run_timestamp) + dataset_name, + test_case, + actual_output, + correct, + similarity_score=similarity_score_val, + run_timestamp=run_timestamp, + ) else: results["incorrect"] += 1 error_msg = result.error.message if result.error else "Unknown error" - results["errors"].append({ - "case": i + 1, - "input": user_input, - "expected": expected, - "actual": None, - "type": "execution_failed", - "error": error_msg - }) + results["errors"].append( + { + "case": i + 1, + "input": user_input, + "expected": expected, + "actual": None, + "type": "execution_failed", + "error": error_msg, + } + ) # Save raw result to CSV save_raw_results_to_csv( - dataset_name, test_case, None, False, error_msg, run_timestamp=run_timestamp) + dataset_name, + test_case, + None, + False, + error_msg, + run_timestamp=run_timestamp, + ) except Exception as e: results["incorrect"] += 1 error_msg = str(e) - results["errors"].append({ - "case": i + 1, - "input": user_input, - "expected": expected, - "actual": None, - "type": "exception", - "error": error_msg - }) + results["errors"].append( + { + "case": i + 1, + "input": user_input, + "expected": expected, + "actual": None, + "type": "exception", + "error": error_msg, + } + ) # Save raw result to CSV save_raw_results_to_csv( - dataset_name, test_case, None, False, error_msg, run_timestamp=run_timestamp) + dataset_name, + test_case, + None, + False, + error_msg, + run_timestamp=run_timestamp, + ) # Store detailed results - results["details"].append({ - "case": i + 1, - "input": user_input, - "expected": expected, - "actual": result.output if 'result' in locals() else None, - "success": result.success if 'result' in locals() else False, - "error": result.error.message if 'result' in locals() and result.error else None - }) - - results["accuracy"] = results["correct"] / \ - results["total_cases"] if results["total_cases"] > 0 else 0 + results["details"].append( + { + "case": i + 1, + "input": user_input, + "expected": expected, + "actual": result.output if "result" in locals() else None, + "success": result.success if "result" in locals() else False, + "error": ( + result.error.message + if "result" in locals() and result.error + else None + ), + } + ) + + results["accuracy"] = ( + results["correct"] / results["total_cases"] if results["total_cases"] > 0 else 0 + ) return results -def generate_markdown_report(results: List[Dict[str, Any]], output_path: Path, run_timestamp: Optional[str] = None, mock_mode: bool = False): +def generate_markdown_report( + results: List[Dict[str, Any]], + output_path: Path, + run_timestamp: Optional[str] = None, + mock_mode: bool = False, +): """Generate a markdown report from evaluation results.""" # Generate the report content mock_indicator = " (MOCK MODE)" if mock_mode else "" @@ -294,11 +340,13 @@ def generate_markdown_report(results: List[Dict[str, Any]], output_path: Path, r report_content += f"- **Case {error['case']}**: {error['input']}\n" report_content += f" - Expected: `{error['expected']}`\n" report_content += f" - Actual: `{error['actual']}`\n" - if error.get('error'): + if error.get("error"): report_content += f" - Error: {error['error']}\n" report_content += "\n" if len(result["errors"]) > 5: - report_content += f"- ... and {len(result['errors']) - 5} more errors\n\n" + report_content += ( + f"- ... and {len(result['errors']) - 5} more errors\n\n" + ) # Detailed results table report_content += "## Detailed Results\n\n" @@ -308,7 +356,7 @@ def generate_markdown_report(results: List[Dict[str, Any]], output_path: Path, r report_content += f"| {result['dataset']} | {result['accuracy']:.1%} | {result['correct']} | {result['total_cases']} | `{result['raw_results_file']}` |\n" # Write to the specified output path - with open(output_path, 'w') as f: + with open(output_path, "w") as f: f.write(report_content) today = datetime.now().strftime("%Y-%m-%d") @@ -318,9 +366,10 @@ def generate_markdown_report(results: List[Dict[str, Any]], output_path: Path, r date_reports_dir.mkdir(parents=True, exist_ok=True) # Create date-based filename - date_output_path = date_reports_dir / \ - f"{output_path.stem}_{run_timestamp}{output_path.suffix}" - with open(date_output_path, 'w') as f: + date_output_path = ( + date_reports_dir / f"{output_path.stem}_{run_timestamp}{output_path.suffix}" + ) + with open(date_output_path, "w") as f: f.write(report_content) @@ -335,7 +384,7 @@ def main(): # Load LLM configuration if provided llm_config = {} if args.llm_config: - with open(args.llm_config, 'r') as f: + with open(args.llm_config, "r") as f: llm_config = yaml.safe_load(f) # Set environment variables for API keys @@ -376,9 +425,13 @@ def main(): # Determine module name based on node name if "llm" in node_name: - module_name = f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node_llm" + module_name = ( + f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node_llm" + ) else: - module_name = f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node" + module_name = ( + f"intent_kit.evals.sample_nodes.{node_name.split('_')[0]}_node" + ) # Load node node = get_node_from_module(module_name, node_name) @@ -394,7 +447,8 @@ def main(): # Print results accuracy = result["accuracy"] print( - f" Accuracy: {accuracy:.1%} ({result['correct']}/{result['total_cases']})") + f" Accuracy: {accuracy:.1%} ({result['correct']}/{result['total_cases']})" + ) print(f" Raw results saved to: {result['raw_results_file']}") if result["errors"]: @@ -422,8 +476,7 @@ def main(): output_path = reports_dir / "evaluation_report.md" - generate_markdown_report(results, output_path, - run_timestamp=run_timestamp) + generate_markdown_report(results, output_path, run_timestamp=run_timestamp) print(f"\nReport generated: {output_path}") # Print summary @@ -431,7 +484,8 @@ def main(): total_correct = sum(r["correct"] for r in results) overall_accuracy = total_correct / total_cases if total_cases > 0 else 0 print( - f"\nOverall Accuracy: {overall_accuracy:.1%} ({total_correct}/{total_cases})") + f"\nOverall Accuracy: {overall_accuracy:.1%} ({total_correct}/{total_cases})" + ) if __name__ == "__main__": diff --git a/intent_kit/evals/sample_nodes/__init__.py b/intent_kit/evals/sample_nodes/__init__.py index f355633..af3d1b5 100644 --- a/intent_kit/evals/sample_nodes/__init__.py +++ b/intent_kit/evals/sample_nodes/__init__.py @@ -1 +1 @@ -"""Sample node implementations for node-level evaluation.""" \ No newline at end of file +"""Sample node implementations for node-level evaluation.""" diff --git a/intent_kit/evals/sample_nodes/classifier_node_llm.py b/intent_kit/evals/sample_nodes/classifier_node_llm.py index 799ac2b..dac54f0 100644 --- a/intent_kit/evals/sample_nodes/classifier_node_llm.py +++ b/intent_kit/evals/sample_nodes/classifier_node_llm.py @@ -5,24 +5,28 @@ from intent_kit.node.base import TreeNode -def extract_weather_args_llm(user_input: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def extract_weather_args_llm( + user_input: str, context: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """Extract weather parameters using LLM.""" from intent_kit.services.llm_factory import LLMFactory # Check for mock mode import os + mock_mode = os.getenv("INTENT_KIT_MOCK_MODE") == "1" if mock_mode: # Mock responses for testing without API calls import re + location_patterns = [ - r'(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)', - r'(?:weather|temperature|forecast)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)', - r'(?:What\'s|How\'s)\s+(?:the\s+)?(?:weather|temperature)\s+(?:like\s+)?(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature|forecast)\s+for\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature)\s+in\s+([A-Za-z\s]+?)(?:\?|$)' + r"(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)", + r"(?:weather|temperature|forecast)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)", + r"(?:What\'s|How\'s)\s+(?:the\s+)?(?:weather|temperature)\s+(?:like\s+)?(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature|forecast)\s+for\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature)\s+in\s+([A-Za-z\s]+?)(?:\?|$)", ] location = "Unknown" @@ -39,14 +43,9 @@ def extract_weather_args_llm(user_input: str, context: Optional[Dict[str, Any]] api_key = os.getenv(f"{provider.upper()}_API_KEY") if not api_key: - raise ValueError( - f"Environment variable {provider.upper()}_API_KEY not set") + raise ValueError(f"Environment variable {provider.upper()}_API_KEY not set") - llm_config = { - "provider": provider, - "model": "gpt-4.1-mini", - "api_key": api_key - } + llm_config = {"provider": provider, "model": "gpt-4.1-mini", "api_key": api_key} try: llm_client = LLMFactory.create_client(llm_config) @@ -80,7 +79,7 @@ def extract_weather_args_llm(user_input: str, context: Optional[Dict[str, Any]] import re # Extract JSON from response - json_match = re.search(r'\{.*\}', response, re.DOTALL) + json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: result = json.loads(json_match.group()) return {"location": result.get("location", "Unknown")} @@ -89,13 +88,14 @@ def extract_weather_args_llm(user_input: str, context: Optional[Dict[str, Any]] # Fallback to regex extraction import re + location_patterns = [ - r'(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)', - r'(?:weather|temperature|forecast)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)', - r'(?:What\'s|How\'s)\s+(?:the\s+)?(?:weather|temperature)\s+(?:like\s+)?(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature|forecast)\s+for\s+([A-Za-z\s]+?)(?:\?|$)', - r'(?:weather|temperature)\s+in\s+([A-Za-z\s]+?)(?:\?|$)' + r"(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)", + r"(?:weather|temperature|forecast)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\s|$)", + r"(?:What\'s|How\'s)\s+(?:the\s+)?(?:weather|temperature)\s+(?:like\s+)?(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature)\s+(?:in|for|at)\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature|forecast)\s+for\s+([A-Za-z\s]+?)(?:\?|$)", + r"(?:weather|temperature)\s+in\s+([A-Za-z\s]+?)(?:\?|$)", ] location = "Unknown" @@ -113,22 +113,26 @@ def weather_handler(location: str, context: IntentContext) -> str: return f"Weather in {location}: Sunny with a chance of rain" -def extract_cancel_args_llm(user_input: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def extract_cancel_args_llm( + user_input: str, context: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """Extract cancellation parameters using LLM.""" from intent_kit.services.llm_factory import LLMFactory # Check for mock mode import os + mock_mode = os.getenv("INTENT_KIT_MOCK_MODE") == "1" if mock_mode: # Mock responses for testing without API calls import re + cancel_patterns = [ - r'cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)', - r'cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\?|$)', - r'(?:I\s+need\s+to\s+)?cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)', - r'(?:cancel|cancellation)\s+(?:of\s+)?(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)' + r"cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", + r"cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\?|$)", + r"(?:I\s+need\s+to\s+)?cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", + r"(?:cancel|cancellation)\s+(?:of\s+)?(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", ] item = "reservation" @@ -145,14 +149,9 @@ def extract_cancel_args_llm(user_input: str, context: Optional[Dict[str, Any]] = api_key = os.getenv(f"{provider.upper()}_API_KEY") if not api_key: - raise ValueError( - f"Environment variable {provider.upper()}_API_KEY not set") + raise ValueError(f"Environment variable {provider.upper()}_API_KEY not set") - llm_config = { - "provider": provider, - "model": "gpt-3.5-turbo", - "api_key": api_key - } + llm_config = {"provider": provider, "model": "gpt-3.5-turbo", "api_key": api_key} try: llm_client = LLMFactory.create_client(llm_config) @@ -186,7 +185,7 @@ def extract_cancel_args_llm(user_input: str, context: Optional[Dict[str, Any]] = import re # Extract JSON from response - json_match = re.search(r'\{.*\}', response, re.DOTALL) + json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: result = json.loads(json_match.group()) return {"item": result.get("item", "reservation")} @@ -195,11 +194,12 @@ def extract_cancel_args_llm(user_input: str, context: Optional[Dict[str, Any]] = # Fallback to regex extraction import re + cancel_patterns = [ - r'cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)', - r'cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\?|$)', - r'(?:I\s+need\s+to\s+)?cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)', - r'(?:cancel|cancellation)\s+(?:of\s+)?(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)' + r"cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", + r"cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\?|$)", + r"(?:I\s+need\s+to\s+)?cancel\s+(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", + r"(?:cancel|cancellation)\s+(?:of\s+)?(?:my\s+)?([^,\s]+(?:\s+[^,\s]+)*?)(?:\s|$)", ] item = "reservation" @@ -223,7 +223,7 @@ def cancel_handler(item: str, context: IntentContext) -> str: param_schema={"location": str}, handler=weather_handler, arg_extractor=extract_weather_args_llm, - description="Get weather information for a location" + description="Get weather information for a location", ) cancel_handler_node = HandlerNode( @@ -231,16 +231,19 @@ def cancel_handler(item: str, context: IntentContext) -> str: param_schema={"item": str}, handler=cancel_handler, arg_extractor=extract_cancel_args_llm, - description="Cancel reservations or bookings" + description="Cancel reservations or bookings", ) -def intent_classifier_llm(user_input: str, children: List[TreeNode], context: Optional[Dict[str, Any]] = None) -> Optional[TreeNode]: +def intent_classifier_llm( + user_input: str, children: List[TreeNode], context: Optional[Dict[str, Any]] = None +) -> Optional[TreeNode]: """Classify user intent using LLM.""" from intent_kit.services.llm_factory import LLMFactory # Check for mock mode import os + mock_mode = os.getenv("INTENT_KIT_MOCK_MODE") == "1" if mock_mode: @@ -259,14 +262,9 @@ def intent_classifier_llm(user_input: str, children: List[TreeNode], context: Op api_key = os.getenv(f"{provider.upper()}_API_KEY") if not api_key: - raise ValueError( - f"Environment variable {provider.upper()}_API_KEY not set") + raise ValueError(f"Environment variable {provider.upper()}_API_KEY not set") - llm_config = { - "provider": provider, - "model": "gpt-3.5-turbo", - "api_key": api_key - } + llm_config = {"provider": provider, "model": "gpt-3.5-turbo", "api_key": api_key} try: llm_client = LLMFactory.create_client(llm_config) @@ -303,18 +301,26 @@ def intent_classifier_llm(user_input: str, children: List[TreeNode], context: Op # Fallback to keyword matching user_input_lower = user_input.lower() - if any(word in user_input_lower for word in ["weather", "temperature", "forecast"]): + if any( + word in user_input_lower for word in ["weather", "temperature", "forecast"] + ): return weather_handler_node - elif any(word in user_input_lower for word in ["cancel", "cancellation", "refund"]): + elif any( + word in user_input_lower for word in ["cancel", "cancellation", "refund"] + ): return cancel_handler_node except Exception as e: print(f"LLM classification failed: {e}") # Fallback to keyword matching user_input_lower = user_input.lower() - if any(word in user_input_lower for word in ["weather", "temperature", "forecast"]): + if any( + word in user_input_lower for word in ["weather", "temperature", "forecast"] + ): return weather_handler_node - elif any(word in user_input_lower for word in ["cancel", "cancellation", "refund"]): + elif any( + word in user_input_lower for word in ["cancel", "cancellation", "refund"] + ): return cancel_handler_node return None @@ -325,5 +331,5 @@ def intent_classifier_llm(user_input: str, children: List[TreeNode], context: Op name="classifier_node_llm", classifier=intent_classifier_llm, children=[weather_handler_node, cancel_handler_node], - description="Route user intents to appropriate handlers using LLM classification" + description="Route user intents to appropriate handlers using LLM classification", ) diff --git a/intent_kit/evals/sample_nodes/handler_node_llm.py b/intent_kit/evals/sample_nodes/handler_node_llm.py index efa2afc..5fc542e 100644 --- a/intent_kit/evals/sample_nodes/handler_node_llm.py +++ b/intent_kit/evals/sample_nodes/handler_node_llm.py @@ -1,33 +1,36 @@ from typing import Optional, Dict, Any from intent_kit.handlers.node import HandlerNode from intent_kit.context import IntentContext -from intent_kit.node.types import ExecutionResult -def extract_booking_args_llm(user_input: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def extract_booking_args_llm( + user_input: str, context: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """Extract booking parameters using LLM.""" from intent_kit.services.llm_factory import LLMFactory # Check for mock mode import os + mock_mode = os.getenv("INTENT_KIT_MOCK_MODE") == "1" if mock_mode: # Mock responses for testing without API calls import re + # Simple regex extraction for mock mode dest_match = re.search( - r'(?:to|for|in)\s+([A-Za-z\s]+?)(?:\s|$)', user_input, re.IGNORECASE) + r"(?:to|for|in)\s+([A-Za-z\s]+?)(?:\s|$)", user_input, re.IGNORECASE + ) destination = dest_match.group(1).strip() if dest_match else "Unknown" - date_match = re.search(r'(?:for|on)\s+(\w+\s+\w+)', - user_input, re.IGNORECASE) + date_match = re.search(r"(?:for|on)\s+(\w+\s+\w+)", user_input, re.IGNORECASE) date = date_match.group(1) if date_match else "ASAP" return { "destination": destination, "date": date, - "user_id": context.get("user_id", "anonymous") if context else "anonymous" + "user_id": context.get("user_id", "anonymous") if context else "anonymous", } # Configure LLM (you can change this to any supported provider) @@ -35,14 +38,9 @@ def extract_booking_args_llm(user_input: str, context: Optional[Dict[str, Any]] api_key = os.getenv(f"{provider.upper()}_API_KEY") if not api_key: - raise ValueError( - f"Environment variable {provider.upper()}_API_KEY not set") + raise ValueError(f"Environment variable {provider.upper()}_API_KEY not set") - llm_config = { - "provider": provider, - "model": "gpt-3.5-turbo", - "api_key": api_key - } + llm_config = {"provider": provider, "model": "gpt-3.5-turbo", "api_key": api_key} try: llm_client = LLMFactory.create_client(llm_config) @@ -81,37 +79,40 @@ def extract_booking_args_llm(user_input: str, context: Optional[Dict[str, Any]] import re # Extract JSON from response - json_match = re.search(r'\{.*\}', response, re.DOTALL) + json_match = re.search(r"\{.*\}", response, re.DOTALL) if json_match: result = json.loads(json_match.group()) # Clean up the date field to remove extra words date = result.get("date", "ASAP") if date != "ASAP": # Remove common prefixes that might be extracted - date = re.sub(r'^(for|to)\s+', '', date, flags=re.IGNORECASE) + date = re.sub(r"^(for|to)\s+", "", date, flags=re.IGNORECASE) return { "destination": result.get("destination", "Unknown"), "date": date, - "user_id": context.get("user_id", "anonymous") if context else "anonymous" + "user_id": ( + context.get("user_id", "anonymous") if context else "anonymous" + ), } except Exception as e: print(f"LLM extraction failed: {e}") # Fallback to simple extraction import re + dest_match = re.search( - r'(?:to|for|in)\s+([A-Za-z\s]+?)(?:\s|$)', user_input, re.IGNORECASE) + r"(?:to|for|in)\s+([A-Za-z\s]+?)(?:\s|$)", user_input, re.IGNORECASE + ) destination = dest_match.group(1).strip() if dest_match else "Unknown" - date_match = re.search(r'(?:for|on)\s+(\w+\s+\w+)', - user_input, re.IGNORECASE) + date_match = re.search(r"(?:for|on)\s+(\w+\s+\w+)", user_input, re.IGNORECASE) date = date_match.group(1) if date_match else "ASAP" return { "destination": destination, "date": date, - "user_id": context.get("user_id", "anonymous") if context else "anonymous" + "user_id": context.get("user_id", "anonymous") if context else "anonymous", } @@ -134,5 +135,5 @@ def booking_handler(destination: str, date: str, context: IntentContext) -> str: arg_extractor=extract_booking_args_llm, context_inputs={"user_id"}, context_outputs={"booking_count", "last_destination"}, - description="Handle flight booking requests with LLM-powered argument extraction" + description="Handle flight booking requests with LLM-powered argument extraction", ) diff --git a/intent_kit/evals/sample_nodes/splitter_node_llm.py b/intent_kit/evals/sample_nodes/splitter_node_llm.py index 45b4640..1959e03 100644 --- a/intent_kit/evals/sample_nodes/splitter_node_llm.py +++ b/intent_kit/evals/sample_nodes/splitter_node_llm.py @@ -1,22 +1,24 @@ from typing import Optional, List, Dict, Any from intent_kit.splitters.node import SplitterNode -from intent_kit.context import IntentContext -def split_text_llm(user_input: str, debug: bool = False, context: Optional[Dict[str, Any]] = None) -> List[str]: +def split_text_llm( + user_input: str, debug: bool = False, context: Optional[Dict[str, Any]] = None +) -> List[str]: """Split user input into multiple intents using LLM.""" from intent_kit.services.llm_factory import LLMFactory # Check for mock mode import os + mock_mode = os.getenv("INTENT_KIT_MOCK_MODE") == "1" if mock_mode: # Mock responses for testing without API calls # Simple splitting based on common conjunctions import re - conjunctions = [" and ", " also ", " plus ", - " as well as ", " furthermore "] + + conjunctions = [" and ", " also ", " plus ", " as well as ", " furthermore "] for conj in conjunctions: if conj in user_input.lower(): parts = user_input.split(conj) @@ -29,14 +31,9 @@ def split_text_llm(user_input: str, debug: bool = False, context: Optional[Dict[ api_key = os.getenv(f"{provider.upper()}_API_KEY") if not api_key: - raise ValueError( - f"Environment variable {provider.upper()}_API_KEY not set") + raise ValueError(f"Environment variable {provider.upper()}_API_KEY not set") - llm_config = { - "provider": provider, - "model": "gpt-4.1-mini", - "api_key": api_key - } + llm_config = {"provider": provider, "model": "gpt-4.1-mini", "api_key": api_key} try: llm_client = LLMFactory.create_client(llm_config) @@ -59,7 +56,7 @@ def split_text_llm(user_input: str, debug: bool = False, context: Optional[Dict[ import re # Extract JSON array from response - json_match = re.search(r'\[.*\]', response, re.DOTALL) + json_match = re.search(r"\[.*\]", response, re.DOTALL) if json_match: result = json.loads(json_match.group()) if isinstance(result, list): @@ -79,7 +76,7 @@ def create_splitter_node_llm(): name="splitter_node_llm", splitter_function=split_text_llm, children=[], - description="Split complex user inputs into multiple intents using LLM" + description="Split complex user inputs into multiple intents using LLM", ) @@ -92,13 +89,8 @@ def __init__(self, splitter_node): self.splitter_function = splitter_node.splitter_function def execute(self, user_input: str, context=None): - chunks = self.splitter_function( - user_input, debug=False, context=context) - return type('Result', (), { - 'success': True, - 'output': chunks, - 'error': None - })() + chunks = self.splitter_function(user_input, debug=False, context=context) + return type("Result", (), {"success": True, "output": chunks, "error": None})() # Export the node creation function diff --git a/intent_kit/exceptions/__init__.py b/intent_kit/exceptions/__init__.py index 7ffff70..273ce79 100644 --- a/intent_kit/exceptions/__init__.py +++ b/intent_kit/exceptions/__init__.py @@ -4,18 +4,26 @@ This module provides Node-related exception classes for the intent-kit project. """ -from typing import Optional, List, Dict, Any +from typing import Optional, List class NodeError(Exception): """Base exception for node-related errors.""" + pass class NodeExecutionError(NodeError): """Raised when a node execution fails.""" - def __init__(self, node_name: str, error_message: str, params=None, node_id: Optional[str] = None, node_path: Optional[List[str]] = None): + def __init__( + self, + node_name: str, + error_message: str, + params=None, + node_id: Optional[str] = None, + node_path: Optional[List[str]] = None, + ): """ Initialize the exception. @@ -39,13 +47,21 @@ def __init__(self, node_name: str, error_message: str, params=None, node_id: Opt class NodeValidationError(NodeError): """Base exception for node validation errors.""" + pass class NodeInputValidationError(NodeValidationError): """Raised when node input validation fails.""" - def __init__(self, node_name: str, validation_error: str, input_data=None, node_id: Optional[str] = None, node_path: Optional[List[str]] = None): + def __init__( + self, + node_name: str, + validation_error: str, + input_data=None, + node_id: Optional[str] = None, + node_path: Optional[List[str]] = None, + ): """ Initialize the exception. @@ -70,7 +86,14 @@ def __init__(self, node_name: str, validation_error: str, input_data=None, node_ class NodeOutputValidationError(NodeValidationError): """Raised when node output validation fails.""" - def __init__(self, node_name: str, validation_error: str, output_data=None, node_id: Optional[str] = None, node_path: Optional[List[str]] = None): + def __init__( + self, + node_name: str, + validation_error: str, + output_data=None, + node_id: Optional[str] = None, + node_path: Optional[List[str]] = None, + ): """ Initialize the exception. diff --git a/intent_kit/graph/__init__.py b/intent_kit/graph/__init__.py index 67ab346..e99da68 100644 --- a/intent_kit/graph/__init__.py +++ b/intent_kit/graph/__init__.py @@ -8,5 +8,5 @@ from .intent_graph import IntentGraph __all__ = [ - 'IntentGraph', + "IntentGraph", ] diff --git a/intent_kit/graph/intent_graph.py b/intent_kit/graph/intent_graph.py index b367d6c..069973b 100644 --- a/intent_kit/graph/intent_graph.py +++ b/intent_kit/graph/intent_graph.py @@ -5,31 +5,36 @@ routing to root nodes, and result aggregation. """ -from typing import Dict, Any, Optional, Callable, List +from typing import Dict, Any, Optional, List from datetime import datetime from intent_kit.utils.logger import Logger from intent_kit.context import IntentContext -from intent_kit.splitters import rule_splitter from intent_kit.types import SplitterFunction, IntentChunk -from intent_kit.graph.validation import validate_splitter_routing, validate_graph_structure, validate_node_types, GraphValidationError +from intent_kit.graph.validation import ( + validate_splitter_routing, + validate_graph_structure, + validate_node_types, + GraphValidationError, +) + # from intent_kit.graph.aggregation import aggregate_results, create_error_dict, create_no_intent_error, create_no_tree_error from intent_kit.node import ExecutionResult from intent_kit.node import ExecutionError from intent_kit.node.enums import NodeType -from intent_kit.exceptions import NodeExecutionError, NodeInputValidationError, NodeOutputValidationError from intent_kit.node import TreeNode import os from intent_kit.classifiers import classify_intent_chunk -from intent_kit.types import IntentAction, IntentClassification +from intent_kit.types import IntentAction # Add imports for visualization try: import networkx as nx - from pyvis.network import Network + from pyvis.network import Network # type: ignore + VIZ_AVAILABLE = True -except ImportError as e: - nx = None - Network = None +except ImportError: + nx = None # type: ignore + Network = None # type: ignore VIZ_AVAILABLE = False @@ -42,7 +47,15 @@ class IntentGraph: Trees emerge naturally from the parent-child relationships between nodes. """ - def __init__(self, root_nodes: Optional[List[TreeNode]] = None, splitter: Optional[SplitterFunction] = None, visualize: bool = False, llm_config: Optional[dict] = None, debug_context: bool = False, context_trace: bool = False): + def __init__( + self, + root_nodes: Optional[List[TreeNode]] = None, + splitter: Optional[SplitterFunction] = None, + visualize: bool = False, + llm_config: Optional[dict] = None, + debug_context: bool = False, + context_trace: bool = False, + ): """ Initialize the IntentGraph with root nodes. @@ -58,10 +71,14 @@ def __init__(self, root_nodes: Optional[List[TreeNode]] = None, splitter: Option # Default to pass-through splitter if none provided if splitter is None: - def pass_through_splitter(user_input: str, debug: bool = False) -> List[IntentChunk]: + + def pass_through_splitter( + user_input: str, debug: bool = False + ) -> List[IntentChunk]: """Pass-through splitter that doesn't split the input.""" return [user_input] - self.splitter = pass_through_splitter + + self.splitter: SplitterFunction = pass_through_splitter else: self.splitter = splitter @@ -89,11 +106,11 @@ def add_root_node(self, root_node: TreeNode, validate: bool = True) -> None: if validate: try: self.validate_graph() - self.logger.info( - "Graph validation passed after adding root node") + self.logger.info("Graph validation passed after adding root node") except GraphValidationError as e: self.logger.error( - f"Graph validation failed after adding root node: {e.message}") + f"Graph validation failed after adding root node: {e.message}" + ) # Remove the node if validation fails and re-raise the error self.root_nodes.remove(root_node) raise e @@ -109,8 +126,7 @@ def remove_root_node(self, root_node: TreeNode) -> None: self.root_nodes.remove(root_node) self.logger.info(f"Removed root node: {root_node.name}") else: - self.logger.warning( - f"Root node '{root_node.name}' not found for removal") + self.logger.warning(f"Root node '{root_node.name}' not found for removal") def list_root_nodes(self) -> List[str]: """ @@ -121,7 +137,9 @@ def list_root_nodes(self) -> List[str]: """ return [node.name for node in self.root_nodes] - def validate_graph(self, validate_routing: bool = True, validate_types: bool = True) -> Dict[str, Any]: + def validate_graph( + self, validate_routing: bool = True, validate_types: bool = True + ) -> Dict[str, Any]: """ Validate the graph structure and routing constraints. @@ -188,7 +206,13 @@ def collect_node(node: TreeNode): return all_nodes - def _call_splitter(self, user_input: str, debug: bool, context: Optional[IntentContext] = None, **splitter_kwargs) -> list: + def _call_splitter( + self, + user_input: str, + debug: bool, + context: Optional[IntentContext] = None, + **splitter_kwargs, + ) -> list: """ Call the splitter function with appropriate parameters. @@ -204,7 +228,9 @@ def _call_splitter(self, user_input: str, debug: bool, context: Optional[IntentC result = self.splitter(user_input, debug, **splitter_kwargs) return list(result) # Convert Sequence to list - def _route_chunk_to_root_node(self, chunk: str, debug: bool = False) -> Optional[TreeNode]: + def _route_chunk_to_root_node( + self, chunk: str, debug: bool = False + ) -> Optional[TreeNode]: """ Route a single chunk to the most appropriate root node. @@ -227,25 +253,36 @@ def _route_chunk_to_root_node(self, chunk: str, debug: bool = False) -> Optional if node.name.lower() in chunk_lower: if debug: self.logger.info( - f"Routed chunk '{chunk}' to root node '{node.name}' by name match") + f"Routed chunk '{chunk}' to root node '{node.name}' by name match" + ) return node # Check for keyword matches (could be enhanced) - keywords = getattr(node, 'keywords', []) + keywords = getattr(node, "keywords", []) for keyword in keywords: if keyword.lower() in chunk_lower: if debug: self.logger.info( - f"Routed chunk '{chunk}' to root node '{node.name}' by keyword '{keyword}'") + f"Routed chunk '{chunk}' to root node '{node.name}' by keyword '{keyword}'" + ) return node # If no specific match, return the first root node as fallback if debug: self.logger.info( - f"No specific match for chunk '{chunk}', using first root node '{self.root_nodes[0].name}' as fallback") + f"No specific match for chunk '{chunk}', using first root node '{self.root_nodes[0].name}' as fallback" + ) return self.root_nodes[0] if self.root_nodes else None - def route(self, user_input: str, context: Optional[IntentContext] = None, debug: bool = False, debug_context: Optional[bool] = None, context_trace: Optional[bool] = None, **splitter_kwargs) -> ExecutionResult: + def route( + self, + user_input: str, + context: Optional[IntentContext] = None, + debug: bool = False, + debug_context: Optional[bool] = None, + context_trace: Optional[bool] = None, + **splitter_kwargs, + ) -> ExecutionResult: """ Route user input through the graph with optional context support. @@ -261,8 +298,12 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: ExecutionResult containing aggregated results and errors from all matched taxonomies """ # Use method parameters if provided, otherwise use graph-level settings - debug_context_enabled = debug_context if debug_context is not None else self.debug_context - context_trace_enabled = context_trace if context_trace is not None else self.context_trace + debug_context_enabled = ( + debug_context if debug_context is not None else self.debug_context + ) + context_trace_enabled = ( + context_trace if context_trace is not None else self.context_trace + ) if debug: self.logger.info(f"Processing input: {user_input}") @@ -276,9 +317,7 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: # Split the input into chunks try: intent_chunks = self._call_splitter( - user_input=user_input, - debug=debug, - **splitter_kwargs + user_input=user_input, debug=debug, **splitter_kwargs ) except Exception as e: @@ -296,8 +335,8 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="SplitterError", message=str(e), node_name="splitter", - node_path=[] - ) + node_path=[], + ), ) if debug: @@ -320,8 +359,8 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="NoIntentFound", message="No intent chunks found", node_name="unhandled_chunk", - node_path=[] - ) + node_path=[], + ), ) # Route each chunk to an appropriate root node @@ -332,7 +371,7 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: # Use a queue to process chunks, with recursion limit chunks_to_process = list(intent_chunks) # Copy the list - processed_chunks = set() # Track processed chunks to avoid infinite loops + processed_chunks: set = set() # Track processed chunks to avoid infinite loops max_recursion_depth = 10 # Prevent infinite recursion while chunks_to_process and len(processed_chunks) < max_recursion_depth: @@ -378,35 +417,43 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="NoRootNodeFound", message=f"No root node found for chunk: '{chunk_text}'", node_name="no_root_node", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) - all_errors.append( - f"No root node found for chunk: '{chunk_text}'") + all_errors.append(f"No root node found for chunk: '{chunk_text}'") if debug: self.logger.error( - f"No root node found for chunk: '{chunk_text}'") + f"No root node found for chunk: '{chunk_text}'" + ) continue try: # Context debugging: capture state before execution context_state_before = None if debug_context_enabled and context: context_state_before = self._capture_context_state( - context, f"before_{root_node.name}") + context, f"before_{root_node.name}" + ) result = root_node.execute(chunk_text, context=context) # Context debugging: capture state after execution if debug_context_enabled and context: context_state_after = self._capture_context_state( - context, f"after_{root_node.name}") + context, f"after_{root_node.name}" + ) self._log_context_changes( - context_state_before, context_state_after, root_node.name, debug, context_trace_enabled) + context_state_before, + context_state_after, + root_node.name, + debug, + context_trace_enabled, + ) if debug: self.logger.info( - f"Root node '{root_node.name}' result: {result}") + f"Root node '{root_node.name}' result: {result}" + ) children_results.append(result) if result.success and result.output is not None: all_outputs.append(result.output) @@ -414,7 +461,8 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: all_params.append(result.params) if result.error: all_errors.append( - f"Root node '{root_node.name}': {result.error.message}") + f"Root node '{root_node.name}': {result.error.message}" + ) except Exception as e: error_message = str(e) error_type = type(e).__name__ @@ -431,22 +479,20 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type=error_type, message=error_message, node_name="unknown", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) all_errors.append( - f"Root node '{root_node.name}' failed: {error_message}") + f"Root node '{root_node.name}' failed: {error_message}" + ) if debug: - self.logger.error( - f"Root node '{root_node.name}' failed: {e}") + self.logger.error(f"Root node '{root_node.name}' failed: {e}") elif action == IntentAction.SPLIT: # Recursively split and route if debug: - self.logger.info( - f"Recursively splitting chunk: '{chunk_text}'") - sub_chunks = self._call_splitter( - chunk_text, debug, **splitter_kwargs) + self.logger.info(f"Recursively splitting chunk: '{chunk_text}'") + sub_chunks = self._call_splitter(chunk_text, debug, **splitter_kwargs) # Add sub_chunks to the front of the queue for processing chunks_to_process = sub_chunks + chunks_to_process elif action == IntentAction.CLARIFY: @@ -464,15 +510,15 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="ClarificationNeeded", message=f"Clarification needed for chunk: '{chunk_text}'", node_name="clarify", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) - all_errors.append( - f"Clarification needed for chunk: '{chunk_text}'") + all_errors.append(f"Clarification needed for chunk: '{chunk_text}'") if debug: self.logger.warning( - f"Clarification needed for chunk: '{chunk_text}'") + f"Clarification needed for chunk: '{chunk_text}'" + ) elif action == IntentAction.REJECT: # Stub: Add a result indicating rejection error_result = ExecutionResult( @@ -488,8 +534,8 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="RejectedChunk", message=f"Rejected chunk: '{chunk_text}'", node_name="reject", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) all_errors.append(f"Rejected chunk: '{chunk_text}'") @@ -510,14 +556,13 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="UnknownAction", message=f"Unknown action for chunk: '{chunk_text}'", node_name="unknown_action", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) all_errors.append(f"Unknown action for chunk: '{chunk_text}'") if debug: - self.logger.error( - f"Unknown action for chunk: '{chunk_text}'") + self.logger.error(f"Unknown action for chunk: '{chunk_text}'") # Check if we hit the recursion limit if len(processed_chunks) >= max_recursion_depth: @@ -534,24 +579,32 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="RecursionLimitExceeded", message=f"Recursion limit exceeded ({max_recursion_depth} chunks processed)", node_name="recursion_limit", - node_path=[] - ) + node_path=[], + ), ) children_results.append(error_result) all_errors.append( - f"Recursion limit exceeded ({max_recursion_depth} chunks processed)") + f"Recursion limit exceeded ({max_recursion_depth} chunks processed)" + ) if debug: self.logger.error( - f"Recursion limit exceeded ({max_recursion_depth} chunks processed)") + f"Recursion limit exceeded ({max_recursion_depth} chunks processed)" + ) # Determine overall success and create aggregated result overall_success = len(all_errors) == 0 and len(children_results) > 0 # Aggregate outputs and params - aggregated_output = all_outputs if len(all_outputs) > 1 else ( - all_outputs[0] if all_outputs else None) - aggregated_params = all_params if len(all_params) > 1 else ( - all_params[0] if all_params else None) + aggregated_output = ( + all_outputs + if len(all_outputs) > 1 + else (all_outputs[0] if all_outputs else None) + ) + aggregated_params = ( + all_params + if len(all_params) > 1 + else (all_params[0] if all_params else None) + ) # Ensure params is a dict or None if aggregated_params is not None and not isinstance(aggregated_params, dict): @@ -564,15 +617,14 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: error_type="AggregatedErrors", message="; ".join(all_errors), node_name="intent_graph", - node_path=[] + node_path=[], ) # Create visualization if requested visualization_html = None if self.visualize: try: - html_path = self._render_execution_graph( - children_results, user_input) + html_path = self._render_execution_graph(children_results, user_input) visualization_html = html_path except Exception as e: self.logger.error(f"Visualization failed: {e}") @@ -587,7 +639,7 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: else: aggregated_output = { "output": aggregated_output, - "visualization_html": visualization_html + "visualization_html": visualization_html, } if debug: @@ -602,39 +654,43 @@ def route(self, user_input: str, context: Optional[IntentContext] = None, debug: node_type=NodeType.GRAPH, input=user_input, output=aggregated_output, - error=aggregated_error + error=aggregated_error, ) - def _render_execution_graph(self, children_results: list[ExecutionResult], user_input: str) -> str: + def _render_execution_graph( + self, children_results: list[ExecutionResult], user_input: str + ) -> str: """ Render the execution path as an interactive HTML graph and return the file path. """ if not VIZ_AVAILABLE: raise ImportError( - "networkx and pyvis are required for visualization. Please install with: uv pip install 'intent-kit[viz]'") + "networkx and pyvis are required for visualization. Please install with: uv pip install 'intent-kit[viz]'" + ) try: # Import here to ensure it's available from pyvis.network import Network # Build the graph from the execution path - net = Network(height="600px", width="100%", - directed=True, notebook=False) + net = Network(height="600px", width="100%", directed=True, notebook=False) net.barnes_hut() execution_paths = [] # Extract execution paths from all children results for result in children_results: # Add the current result to the path - execution_paths.append({ - "node_name": result.node_name, - "node_type": result.node_type, - "success": result.success, - "input": result.input, - "output": result.output, - "error": result.error, - "params": result.params - }) + execution_paths.append( + { + "node_name": result.node_name, + "node_type": result.node_type, + "success": result.success, + "input": result.input, + "output": result.output, + "error": result.error, + "params": result.params, + } + ) # Add child results recursively for child_result in result.children_results: @@ -646,11 +702,13 @@ def _render_execution_graph(self, children_results: list[ExecutionResult], user_ execution_paths = [] for result in children_results: if result.error: - execution_paths.append({ - "node_name": result.node_name, - "node_type": "error", - "error": result.error - }) + execution_paths.append( + { + "node_name": result.node_name, + "node_type": "error", + "error": result.error, + } + ) # Add nodes and edges last_node_id = None @@ -662,11 +720,11 @@ def _render_execution_graph(self, children_results: list[ExecutionResult], user_ elif node.get("output"): label += f"\nOutput: {str(node['output'])[:40]}" # Color coding - if node['node_type'] == 'error': + if node["node_type"] == "error": color = "#ffcccc" # red - elif node['node_type'] == 'classifier': + elif node["node_type"] == "classifier": color = "#99ccff" # blue - elif node['node_type'] == 'intent': + elif node["node_type"] == "intent": color = "#ccffcc" # green else: color = "#ccccff" # fallback @@ -675,18 +733,18 @@ def _render_execution_graph(self, children_results: list[ExecutionResult], user_ net.add_edge(last_node_id, node_id) last_node_id = node_id if not execution_paths: - net.add_node("no_path", label="No execution path", - color="#cccccc") + net.add_node("no_path", label="No execution path", color="#cccccc") # Save to HTML file html_dir = os.path.join(os.getcwd(), "intentkit_graphs") os.makedirs(html_dir, exist_ok=True) html_path = os.path.join( - html_dir, f"intent_graph_{abs(hash(user_input)) % 100000}.html") + html_dir, f"intent_graph_{abs(hash(user_input)) % 100000}.html" + ) # Generate HTML and write to file manually html_content = net.generate_html() - with open(html_path, 'w', encoding='utf-8') as f: + with open(html_path, "w", encoding="utf-8") as f: f.write(html_content) return html_path @@ -707,15 +765,17 @@ def _extract_execution_paths(self, result: ExecutionResult) -> list: paths = [] # Add current node - paths.append({ - "node_name": result.node_name, - "node_type": result.node_type, - "success": result.success, - "input": result.input, - "output": result.output, - "error": result.error, - "params": result.params - }) + paths.append( + { + "node_name": result.node_name, + "node_type": result.node_type, + "success": result.success, + "input": result.input, + "output": result.output, + "error": result.error, + "params": result.params, + } + ) # Recursively add children for child_result in result.children_results: @@ -724,7 +784,9 @@ def _extract_execution_paths(self, result: ExecutionResult) -> list: return paths - def _capture_context_state(self, context: IntentContext, label: str) -> Dict[str, Any]: + def _capture_context_state( + self, context: IntentContext, label: str + ) -> Dict[str, Any]: """ Capture the current state of the context for debugging without adding to history. @@ -735,14 +797,14 @@ def _capture_context_state(self, context: IntentContext, label: str) -> Dict[str Returns: Dictionary containing context state """ - state = { + state: Dict[str, Any] = { "timestamp": datetime.now().isoformat(), "label": label, "session_id": context.session_id, "fields": {}, "field_count": len(context.keys()), "history_count": len(context.get_history()), - "error_count": context.error_count() + "error_count": context.error_count(), } # Capture all field values directly from internal state to avoid GET operations @@ -754,16 +816,20 @@ def _capture_context_state(self, context: IntentContext, label: str) -> Dict[str "created_at": field.created_at, "last_modified": field.last_modified, "modified_by": field.modified_by, - "value": field.value - } - state["fields"][key] = { - "value": value, - "metadata": metadata + "value": field.value, } + state["fields"][key] = {"value": value, "metadata": metadata} return state - def _log_context_changes(self, state_before: Optional[Dict[str, Any]], state_after: Optional[Dict[str, Any]], node_name: str, debug: bool, context_trace: bool) -> None: + def _log_context_changes( + self, + state_before: Optional[Dict[str, Any]], + state_after: Optional[Dict[str, Any]], + node_name: str, + debug: bool, + context_trace: bool, + ) -> None: """ Log context changes between before and after node execution. @@ -783,22 +849,27 @@ def _log_context_changes(self, state_before: Optional[Dict[str, Any]], state_aft field_count_after = state_after.get("field_count", 0) if field_count_after > field_count_before: - new_fields = set(state_after["fields"].keys( - )) - set(state_before["fields"].keys()) + new_fields = set(state_after["fields"].keys()) - set( + state_before["fields"].keys() + ) self.logger.info( - f"Node '{node_name}' added {len(new_fields)} new context fields: {new_fields}") + f"Node '{node_name}' added {len(new_fields)} new context fields: {new_fields}" + ) elif field_count_after < field_count_before: - removed_fields = set( - state_before["fields"].keys()) - set(state_after["fields"].keys()) + removed_fields = set(state_before["fields"].keys()) - set( + state_after["fields"].keys() + ) self.logger.info( - f"Node '{node_name}' removed {len(removed_fields)} context fields: {removed_fields}") + f"Node '{node_name}' removed {len(removed_fields)} context fields: {removed_fields}" + ) # Detailed context tracing if context_trace: - self._log_detailed_context_trace( - state_before, state_after, node_name) + self._log_detailed_context_trace(state_before, state_after, node_name) - def _log_detailed_context_trace(self, state_before: Dict[str, Any], state_after: Dict[str, Any], node_name: str) -> None: + def _log_detailed_context_trace( + self, state_before: Dict[str, Any], state_after: Dict[str, Any], node_name: str + ) -> None: """ Log detailed context trace with field-level changes. @@ -813,24 +884,36 @@ def _log_detailed_context_trace(self, state_before: Dict[str, Any], state_after: # Find changed fields changed_fields = [] for key in set(fields_before.keys()) | set(fields_after.keys()): - value_before = fields_before.get(key, {}).get( - "value") if key in fields_before else None - value_after = fields_after.get(key, {}).get( - "value") if key in fields_after else None + value_before = ( + fields_before.get(key, {}).get("value") + if key in fields_before + else None + ) + value_after = ( + fields_after.get(key, {}).get("value") if key in fields_after else None + ) if value_before != value_after: - changed_fields.append({ - "key": key, - "before": value_before, - "after": value_after, - "action": "modified" if key in fields_before and key in fields_after else "added" if key in fields_after else "removed" - }) + changed_fields.append( + { + "key": key, + "before": value_before, + "after": value_after, + "action": ( + "modified" + if key in fields_before and key in fields_after + else "added" if key in fields_after else "removed" + ), + } + ) if changed_fields: self.logger.info(f"Context trace for node '{node_name}':") for change in changed_fields: self.logger.info( - f" {change['action'].upper()}: {change['key']} = {change['after']} (was: {change['before']})") + f" {change['action'].upper()}: {change['key']} = {change['after']} (was: {change['before']})" + ) else: self.logger.info( - f"Context trace for node '{node_name}': No changes detected") + f"Context trace for node '{node_name}': No changes detected" + ) diff --git a/intent_kit/graph/validation.py b/intent_kit/graph/validation.py index 838c77d..6c81414 100644 --- a/intent_kit/graph/validation.py +++ b/intent_kit/graph/validation.py @@ -5,7 +5,7 @@ and graph structure in intent graphs. """ -from typing import List, Set, Dict, Any, Optional +from typing import List, Dict, Any, Optional from intent_kit.node import TreeNode from intent_kit.node.enums import NodeType from intent_kit.utils.logger import Logger @@ -14,7 +14,13 @@ class GraphValidationError(Exception): """Exception raised when graph validation fails.""" - def __init__(self, message: str, node_name: Optional[str] = None, child_name: Optional[str] = None, child_type: Optional[NodeType] = None): + def __init__( + self, + message: str, + node_name: Optional[str] = None, + child_name: Optional[str] = None, + child_type: Optional[NodeType] = None, + ): self.message = message self.node_name = node_name self.child_name = child_name @@ -51,11 +57,12 @@ def validate_splitter_routing(graph_nodes: List[TreeNode]) -> None: message=error_msg, node_name=node.name, child_name=child.name, - child_type=child.node_type + child_type=child.node_type, ) else: logger.debug( - f" āœ“ Splitter '{node.name}' correctly routes to classifier '{child.name}'") + f" āœ“ Splitter '{node.name}' correctly routes to classifier '{child.name}'" + ) logger.info("Splitter routing validation passed āœ“") @@ -77,7 +84,7 @@ def validate_graph_structure(graph_nodes: List[TreeNode]) -> Dict[str, Any]: all_nodes = _collect_all_nodes(graph_nodes) # Count nodes by type - node_counts = {} + node_counts: Dict[Any, int] = {} for node in all_nodes: node_type = node.node_type node_counts[node_type] = node_counts.get(node_type, 0) + 1 @@ -102,11 +109,13 @@ def validate_graph_structure(graph_nodes: List[TreeNode]) -> Dict[str, Any]: "routing_valid": routing_valid, "has_cycles": has_cycles, "orphaned_nodes": [node.name for node in orphaned_nodes], - "orphaned_count": len(orphaned_nodes) + "orphaned_count": len(orphaned_nodes), } - logger.info(f"Graph validation complete: {stats['total_nodes']} total nodes, " - f"routing valid: {routing_valid}, cycles: {has_cycles}") + logger.info( + f"Graph validation complete: {stats['total_nodes']} total nodes, " + f"routing valid: {routing_valid}, cycles: {has_cycles}" + ) return stats @@ -189,9 +198,7 @@ def validate_node_types(nodes: List[TreeNode]) -> None: error_msg = f"Invalid node type '{node.node_type}' for node '{node.name}'. Valid types: {NodeType}" logger.error(error_msg) raise GraphValidationError( - message=error_msg, - node_name=node.name, - child_type=node.node_type + message=error_msg, node_name=node.name, child_type=node.node_type ) logger.info("Node type validation passed āœ“") diff --git a/intent_kit/handlers/node.py b/intent_kit/handlers/node.py index 4e0bab3..5bdc9fe 100644 --- a/intent_kit/handlers/node.py +++ b/intent_kit/handlers/node.py @@ -4,7 +4,10 @@ from intent_kit.context import IntentContext from intent_kit.context.dependencies import declare_dependencies from intent_kit.node.types import ExecutionResult, ExecutionError -from intent_kit.handlers.remediation import get_remediation_strategy, RemediationStrategy +from intent_kit.handlers.remediation import ( + get_remediation_strategy, + RemediationStrategy, +) class HandlerNode(TreeNode): @@ -22,8 +25,7 @@ def __init__( output_validator: Optional[Callable[[Any], bool]] = None, description: str = "", parent: Optional["TreeNode"] = None, - remediation_strategies: Optional[List[Union[str, - RemediationStrategy]]] = None + remediation_strategies: Optional[List[Union[str, RemediationStrategy]]] = None, ): super().__init__(name=name, description=description, children=[], parent=parent) self.param_schema = param_schema @@ -36,7 +38,7 @@ def __init__( self.context_dependencies = declare_dependencies( inputs=self.context_inputs, outputs=self.context_outputs, - description=f"Context dependencies for intent '{self.name}'" + description=f"Context dependencies for intent '{self.name}'", ) # Store remediation strategies @@ -47,17 +49,22 @@ def node_type(self) -> NodeType: """Get the type of this node.""" return NodeType.HANDLER - def execute(self, user_input: str, context: Optional[IntentContext] = None) -> ExecutionResult: + def execute( + self, user_input: str, context: Optional[IntentContext] = None + ) -> ExecutionResult: try: context_dict: Optional[Dict[str, Any]] = None if context: - context_dict = {key: context.get( - key) for key in self.context_inputs if context.has(key)} - extracted_params = self.arg_extractor( - user_input, context_dict or {}) + context_dict = { + key: context.get(key) + for key in self.context_inputs + if context.has(key) + } + extracted_params = self.arg_extractor(user_input, context_dict or {}) except Exception as e: self.logger.error( - f"Argument extraction failed for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Argument extraction failed for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) return ExecutionResult( success=False, node_name=self.name, @@ -69,16 +76,17 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type=type(e).__name__, message=str(e), node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=None, - children_results=[] + children_results=[], ) if self.input_validator: try: if not self.input_validator(extracted_params): self.logger.error( - f"Input validation failed for intent '{self.name}' (Path: {'.'.join(self.get_path())})") + f"Input validation failed for intent '{self.name}' (Path: {'.'.join(self.get_path())})" + ) return ExecutionResult( success=False, node_name=self.name, @@ -90,14 +98,15 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type="InputValidationError", message="Input validation failed", node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=extracted_params, - children_results=[] + children_results=[], ) except Exception as e: self.logger.error( - f"Input validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Input validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) return ExecutionResult( success=False, node_name=self.name, @@ -109,16 +118,17 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type=type(e).__name__, message=str(e), node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=extracted_params, - children_results=[] + children_results=[], ) try: validated_params = self._validate_types(extracted_params) except Exception as e: self.logger.error( - f"Type validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Type validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) return ExecutionResult( success=False, node_name=self.name, @@ -130,10 +140,10 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type=type(e).__name__, message=str(e), node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=extracted_params, - children_results=[] + children_results=[], ) try: if context is not None: @@ -142,21 +152,22 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E output = self.handler(**validated_params) except Exception as e: self.logger.error( - f"Handler execution error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Handler execution error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) # Try remediation strategies error = ExecutionError( error_type=type(e).__name__, message=str(e), node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ) remediation_result = self._execute_remediation_strategies( user_input=user_input, context=context, original_error=error, - validated_params=validated_params + validated_params=validated_params, ) if remediation_result: @@ -172,13 +183,14 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E output=None, error=error, params=validated_params, - children_results=[] + children_results=[], ) if self.output_validator: try: if not self.output_validator(output): self.logger.error( - f"Output validation failed for intent '{self.name}' (Path: {'.'.join(self.get_path())})") + f"Output validation failed for intent '{self.name}' (Path: {'.'.join(self.get_path())})" + ) return ExecutionResult( success=False, node_name=self.name, @@ -190,14 +202,15 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type="OutputValidationError", message="Output validation failed", node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=validated_params, - children_results=[] + children_results=[], ) except Exception as e: self.logger.error( - f"Output validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Output validation error for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) return ExecutionResult( success=False, node_name=self.name, @@ -209,10 +222,10 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type=type(e).__name__, message=str(e), node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params=validated_params, - children_results=[] + children_results=[], ) return ExecutionResult( success=True, @@ -223,7 +236,7 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E output=output, error=None, params=validated_params, - children_results=[] + children_results=[], ) def _execute_remediation_strategies( @@ -231,7 +244,7 @@ def _execute_remediation_strategies( user_input: str, context: Optional[IntentContext] = None, original_error: Optional[ExecutionError] = None, - validated_params: Optional[Dict[str, Any]] = None + validated_params: Optional[Dict[str, Any]] = None, ) -> Optional[ExecutionResult]: """Execute remediation strategies in order until one succeeds.""" if not self.remediation_strategies: @@ -245,14 +258,16 @@ def _execute_remediation_strategies( strategy = get_remediation_strategy(strategy_item) if not strategy: self.logger.warning( - f"Remediation strategy '{strategy_item}' not found in registry") + f"Remediation strategy '{strategy_item}' not found in registry" + ) continue elif isinstance(strategy_item, RemediationStrategy): # Direct strategy object strategy = strategy_item else: self.logger.warning( - f"Invalid remediation strategy type: {type(strategy_item)}") + f"Invalid remediation strategy type: {type(strategy_item)}" + ) continue try: @@ -262,18 +277,21 @@ def _execute_remediation_strategies( context=context, original_error=original_error, handler_func=self.handler, - validated_params=validated_params + validated_params=validated_params, ) if result and result.success: self.logger.info( - f"Remediation strategy '{strategy.name}' succeeded for {self.name}") + f"Remediation strategy '{strategy.name}' succeeded for {self.name}" + ) return result else: self.logger.warning( - f"Remediation strategy '{strategy.name}' failed for {self.name}") + f"Remediation strategy '{strategy.name}' failed for {self.name}" + ) except Exception as e: self.logger.error( - f"Remediation strategy '{strategy.name}' error for {self.name}: {type(e).__name__}: {str(e)}") + f"Remediation strategy '{strategy.name}' error for {self.name}: {type(e).__name__}: {str(e)}" + ) self.logger.error(f"All remediation strategies failed for {self.name}") return None @@ -283,38 +301,47 @@ def _validate_types(self, params: Dict[str, Any]) -> Dict[str, Any]: for param_name, expected_type in self.param_schema.items(): if param_name not in params: self.logger.error( - f"Missing required parameter '{param_name}' for intent '{self.name}' (Path: {'.'.join(self.get_path())})") + f"Missing required parameter '{param_name}' for intent '{self.name}' (Path: {'.'.join(self.get_path())})" + ) raise Exception(f"Missing required parameter '{param_name}'") param_value = params[param_name] - if expected_type == str: + if isinstance(expected_type, type) and expected_type is str: if not isinstance(param_value, str): self.logger.error( - f"Parameter '{param_name}' must be a string, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())})") + f"Parameter '{param_name}' must be a string, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())})" + ) raise Exception( - f"Parameter '{param_name}' must be a string, got {type(param_value).__name__}") - elif expected_type == int: + f"Parameter '{param_name}' must be a string, got {type(param_value).__name__}" + ) + elif isinstance(expected_type, type) and expected_type is int: try: param_value = int(param_value) except (ValueError, TypeError) as e: self.logger.error( - f"Parameter '{param_name}' must be an integer, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Parameter '{param_name}' must be an integer, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) raise Exception( - f"Parameter '{param_name}' must be an integer, got {type(param_value).__name__}: {type(e).__name__}: {str(e)}") - elif expected_type == float: + f"Parameter '{param_name}' must be an integer, got {type(param_value).__name__}: {type(e).__name__}: {str(e)}" + ) + elif isinstance(expected_type, type) and expected_type is float: try: param_value = float(param_value) except (ValueError, TypeError) as e: self.logger.error( - f"Parameter '{param_name}' must be a number, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}") + f"Parameter '{param_name}' must be a number, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())}): {type(e).__name__}: {str(e)}" + ) raise Exception( - f"Parameter '{param_name}' must be a number, got {type(param_value).__name__}: {type(e).__name__}: {str(e)}") - elif expected_type == bool: + f"Parameter '{param_name}' must be a number, got {type(param_value).__name__}: {type(e).__name__}: {str(e)}" + ) + elif isinstance(expected_type, type) and expected_type is bool: if isinstance(param_value, str): - param_value = param_value.lower() in ('true', '1', 'yes', 'on') + param_value = param_value.lower() in ("true", "1", "yes", "on") elif not isinstance(param_value, bool): self.logger.error( - f"Parameter '{param_name}' must be a boolean, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())})") + f"Parameter '{param_name}' must be a boolean, got {type(param_value).__name__} for intent '{self.name}' (Path: {'.'.join(self.get_path())})" + ) raise Exception( - f"Parameter '{param_name}' must be a boolean, got {type(param_value).__name__}") + f"Parameter '{param_name}' must be a boolean, got {type(param_value).__name__}" + ) validated_params[param_name] = param_value return validated_params diff --git a/intent_kit/handlers/remediation.py b/intent_kit/handlers/remediation.py index ca571a9..8c1f8ff 100644 --- a/intent_kit/handlers/remediation.py +++ b/intent_kit/handlers/remediation.py @@ -29,7 +29,7 @@ def execute( user_input: str, context: Optional[IntentContext] = None, original_error: Optional[ExecutionError] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """ Execute the remediation strategy. @@ -51,8 +51,10 @@ class RetryOnFailStrategy(RemediationStrategy): """Simple retry strategy with exponential backoff.""" def __init__(self, max_attempts: int = 3, base_delay: float = 1.0): - super().__init__("retry_on_fail", - f"Retry up to {max_attempts} times with exponential backoff") + super().__init__( + "retry_on_fail", + f"Retry up to {max_attempts} times with exponential backoff", + ) self.max_attempts = max_attempts self.base_delay = base_delay @@ -64,18 +66,20 @@ def execute( original_error: Optional[ExecutionError] = None, handler_func: Optional[Callable] = None, validated_params: Optional[Dict[str, Any]] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Retry the handler function with the same parameters.""" if not handler_func or validated_params is None: self.logger.warning( - f"RetryOnFailStrategy: Missing handler_func or validated_params for {node_name}") + f"RetryOnFailStrategy: Missing handler_func or validated_params for {node_name}" + ) return None for attempt in range(1, self.max_attempts + 1): try: self.logger.info( - f"RetryOnFailStrategy: Attempt {attempt}/{self.max_attempts} for {node_name}") + f"RetryOnFailStrategy: Attempt {attempt}/{self.max_attempts} for {node_name}" + ) # Add context if available if context is not None: @@ -84,7 +88,8 @@ def execute( output = handler_func(**validated_params) self.logger.info( - f"RetryOnFailStrategy: Success on attempt {attempt} for {node_name}") + f"RetryOnFailStrategy: Success on attempt {attempt} for {node_name}" + ) return ExecutionResult( success=True, @@ -95,22 +100,26 @@ def execute( output=output, error=None, params=validated_params, - children_results=[] + children_results=[], ) except Exception as e: self.logger.warning( - f"RetryOnFailStrategy: Attempt {attempt} failed for {node_name}: {type(e).__name__}: {str(e)}") + f"RetryOnFailStrategy: Attempt {attempt} failed for {node_name}: {type(e).__name__}: {str(e)}" + ) if attempt < self.max_attempts: - delay = self.base_delay * \ - (2 ** (attempt - 1)) # Exponential backoff + delay = self.base_delay * ( + 2 ** (attempt - 1) + ) # Exponential backoff self.logger.info( - f"RetryOnFailStrategy: Waiting {delay}s before retry") + f"RetryOnFailStrategy: Waiting {delay}s before retry" + ) time.sleep(delay) self.logger.error( - f"RetryOnFailStrategy: All {self.max_attempts} attempts failed for {node_name}") + f"RetryOnFailStrategy: All {self.max_attempts} attempts failed for {node_name}" + ) return None @@ -118,8 +127,7 @@ class FallbackToAnotherNodeStrategy(RemediationStrategy): """Fallback to a specified alternative handler.""" def __init__(self, fallback_handler: Callable, fallback_name: str = "fallback"): - super().__init__("fallback_to_another_node", - f"Fallback to {fallback_name}") + super().__init__("fallback_to_another_node", f"Fallback to {fallback_name}") self.fallback_handler = fallback_handler self.fallback_name = fallback_name @@ -130,25 +138,26 @@ def execute( context: Optional[IntentContext] = None, original_error: Optional[ExecutionError] = None, validated_params: Optional[Dict[str, Any]] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Execute the fallback handler.""" try: self.logger.info( - f"FallbackToAnotherNodeStrategy: Executing {self.fallback_name} for {node_name}") + f"FallbackToAnotherNodeStrategy: Executing {self.fallback_name} for {node_name}" + ) # Use the same parameters if possible, otherwise use minimal params if validated_params is not None: if context is not None: - output = self.fallback_handler( - **validated_params, context=context) + output = self.fallback_handler(**validated_params, context=context) else: output = self.fallback_handler(**validated_params) else: # Minimal fallback with just the input if context is not None: output = self.fallback_handler( - user_input=user_input, context=context) + user_input=user_input, context=context + ) else: output = self.fallback_handler(user_input=user_input) @@ -161,7 +170,7 @@ def execute( output=output, error=None, params=validated_params or {}, - children_results=[] + children_results=[], ) except Exception as e: @@ -175,8 +184,9 @@ class SelfReflectStrategy(RemediationStrategy): """LLM critiques its own output and retries with improved approach.""" def __init__(self, llm_config: Dict[str, Any], max_reflections: int = 2): - super().__init__("self_reflect", - f"LLM self-reflection with up to {max_reflections} attempts") + super().__init__( + "self_reflect", f"LLM self-reflection with up to {max_reflections} attempts" + ) self.llm_config = llm_config self.max_reflections = max_reflections @@ -188,21 +198,24 @@ def execute( original_error: Optional[ExecutionError] = None, handler_func: Optional[Callable] = None, validated_params: Optional[Dict[str, Any]] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Use LLM to critique and improve the approach.""" if not handler_func or validated_params is None: self.logger.warning( - f"SelfReflectStrategy: Missing handler_func or validated_params for {node_name}") + f"SelfReflectStrategy: Missing handler_func or validated_params for {node_name}" + ) return None from intent_kit.services.llm_factory import LLMFactory + llm_client = LLMFactory.create_client(self.llm_config) for reflection in range(self.max_reflections): try: self.logger.info( - f"SelfReflectStrategy: Reflection {reflection + 1}/{self.max_reflections} for {node_name}") + f"SelfReflectStrategy: Reflection {reflection + 1}/{self.max_reflections} for {node_name}" + ) # Create reflection prompt reflection_prompt = f""" @@ -229,23 +242,24 @@ def execute( reflection_response = llm_client.generate(reflection_prompt) try: - reflection_data = extract_json_from_text( - reflection_response) or {} + reflection_data = extract_json_from_text(reflection_response) or {} self.logger.info( - f"SelfReflectStrategy: LLM reflection for {node_name}: {reflection_data.get('analysis', 'No analysis')}") + f"SelfReflectStrategy: LLM reflection for {node_name}: {reflection_data.get('analysis', 'No analysis')}" + ) # Try with modified parameters if suggested modified_params = reflection_data.get( - 'modified_params', validated_params) + "modified_params", validated_params + ) if context is not None: - output = handler_func( - **modified_params, context=context) + output = handler_func(**modified_params, context=context) else: output = handler_func(**modified_params) self.logger.info( - f"SelfReflectStrategy: Success after reflection {reflection + 1} for {node_name}") + f"SelfReflectStrategy: Success after reflection {reflection + 1} for {node_name}" + ) return ExecutionResult( success=True, @@ -256,16 +270,16 @@ def execute( output=output, error=None, params=modified_params, - children_results=[] + children_results=[], ) except json.JSONDecodeError: self.logger.warning( - f"SelfReflectStrategy: Invalid JSON response from LLM for {node_name}") + f"SelfReflectStrategy: Invalid JSON response from LLM for {node_name}" + ) # Try with original parameters as fallback if context is not None: - output = handler_func( - **validated_params, context=context) + output = handler_func(**validated_params, context=context) else: output = handler_func(**validated_params) @@ -278,15 +292,17 @@ def execute( output=output, error=None, params=validated_params, - children_results=[] + children_results=[], ) except Exception as e: self.logger.warning( - f"SelfReflectStrategy: Reflection {reflection + 1} failed for {node_name}: {type(e).__name__}: {str(e)}") + f"SelfReflectStrategy: Reflection {reflection + 1} failed for {node_name}: {type(e).__name__}: {str(e)}" + ) self.logger.error( - f"SelfReflectStrategy: All {self.max_reflections} reflections failed for {node_name}") + f"SelfReflectStrategy: All {self.max_reflections} reflections failed for {node_name}" + ) return None @@ -294,8 +310,10 @@ class ConsensusVoteStrategy(RemediationStrategy): """Ensemble voting among multiple LLM approaches.""" def __init__(self, llm_configs: List[Dict[str, Any]], vote_threshold: float = 0.6): - super().__init__("consensus_vote", - f"Ensemble voting with {len(llm_configs)} models, threshold {vote_threshold}") + super().__init__( + "consensus_vote", + f"Ensemble voting with {len(llm_configs)} models, threshold {vote_threshold}", + ) self.llm_configs = llm_configs self.vote_threshold = vote_threshold @@ -307,12 +325,13 @@ def execute( original_error: Optional[ExecutionError] = None, handler_func: Optional[Callable] = None, validated_params: Optional[Dict[str, Any]] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Use multiple LLMs to vote on the best approach.""" if not handler_func or validated_params is None: self.logger.warning( - f"ConsensusVoteStrategy: Missing handler_func or validated_params for {node_name}") + f"ConsensusVoteStrategy: Missing handler_func or validated_params for {node_name}" + ) return None from intent_kit.services.llm_factory import LLMFactory @@ -349,7 +368,8 @@ def execute( for i, llm_config in enumerate(self.llm_configs): try: self.logger.info( - f"ConsensusVoteStrategy: Getting vote {i + 1}/{len(self.llm_configs)} for {node_name}") + f"ConsensusVoteStrategy: Getting vote {i + 1}/{len(self.llm_configs)} for {node_name}" + ) llm_client = LLMFactory.create_client(llm_config) vote_response = llm_client.generate(voting_prompt) @@ -358,7 +378,7 @@ def execute( vote_data = extract_json_from_text(vote_response) or {} # Ensure modified_params is properly structured - modified_params = vote_data.get('modified_params', {}) + modified_params = vote_data.get("modified_params", {}) if not isinstance(modified_params, dict): modified_params = {} @@ -370,21 +390,24 @@ def execute( for key, original_value in validated_params.items(): if key in final_params: new_value = final_params[key] - if isinstance(original_value, int) and isinstance(new_value, str): + if isinstance(original_value, int) and isinstance( + new_value, str + ): try: # Try to convert string to int final_params[key] = int(new_value) except (ValueError, TypeError): # If conversion fails, try to evaluate simple expressions - if new_value == 'abs(x)': + if new_value == "abs(x)": final_params[key] = abs(original_value) - elif new_value == 'max(0, x)': - final_params[key] = max( - 0, original_value) + elif new_value == "max(0, x)": + final_params[key] = max(0, original_value) else: # Keep original value if conversion fails final_params[key] = original_value - elif isinstance(original_value, float) and isinstance(new_value, str): + elif isinstance(original_value, float) and isinstance( + new_value, str + ): try: final_params[key] = float(new_value) except (ValueError, TypeError): @@ -393,55 +416,69 @@ def execute( # Apply automatic parameter modifications if LLM didn't suggest any if not modified_params: for key, original_value in validated_params.items(): - if isinstance(original_value, (int, float)) and original_value < 0: + if ( + isinstance(original_value, (int, float)) + and original_value < 0 + ): # For negative numbers, use absolute value final_params[key] = abs(original_value) - votes.append({ - 'model': f"model_{i}", - 'approach': vote_data.get('approach', 'unknown'), - 'confidence': vote_data.get('confidence', 0.5), - 'modified_params': final_params, - 'reasoning': vote_data.get('reasoning', 'No reasoning provided') - }) + votes.append( + { + "model": f"model_{i}", + "approach": vote_data.get("approach", "unknown"), + "confidence": vote_data.get("confidence", 0.5), + "modified_params": final_params, + "reasoning": vote_data.get( + "reasoning", "No reasoning provided" + ), + } + ) successful_votes += 1 except json.JSONDecodeError: self.logger.warning( - f"ConsensusVoteStrategy: Invalid JSON from model {i} for {node_name}") + f"ConsensusVoteStrategy: Invalid JSON from model {i} for {node_name}" + ) except Exception as e: self.logger.warning( - f"ConsensusVoteStrategy: Model {i} failed for {node_name}: {type(e).__name__}: {str(e)}") + f"ConsensusVoteStrategy: Model {i} failed for {node_name}: {type(e).__name__}: {str(e)}" + ) if not votes: self.logger.error( - f"ConsensusVoteStrategy: No successful votes for {node_name}") + f"ConsensusVoteStrategy: No successful votes for {node_name}" + ) return None # Calculate consensus - total_confidence = sum(vote['confidence'] for vote in votes) + total_confidence = sum(vote["confidence"] for vote in votes) avg_confidence = total_confidence / len(votes) self.logger.info( - f"ConsensusVoteStrategy: {successful_votes}/{len(self.llm_configs)} models voted for {node_name}, avg confidence: {avg_confidence:.2f}") + f"ConsensusVoteStrategy: {successful_votes}/{len(self.llm_configs)} models voted for {node_name}, avg confidence: {avg_confidence:.2f}" + ) if avg_confidence >= self.vote_threshold: # Use the highest confidence vote - best_vote = max(votes, key=lambda v: v['confidence']) + best_vote = max(votes, key=lambda v: v["confidence"]) try: self.logger.info( - f"ConsensusVoteStrategy: Attempting execution with params: {best_vote['modified_params']}") + f"ConsensusVoteStrategy: Attempting execution with params: {best_vote['modified_params']}" + ) if context is not None: output = handler_func( - **best_vote['modified_params'], context=context) + **best_vote["modified_params"], context=context + ) else: - output = handler_func(**best_vote['modified_params']) + output = handler_func(**best_vote["modified_params"]) self.logger.info( - f"ConsensusVoteStrategy: Success with consensus approach for {node_name}") + f"ConsensusVoteStrategy: Success with consensus approach for {node_name}" + ) return ExecutionResult( success=True, @@ -451,27 +488,34 @@ def execute( input=user_input, output=output, error=None, - params=best_vote['modified_params'], - children_results=[] + params=best_vote["modified_params"], + children_results=[], ) except Exception as e: self.logger.error( - f"ConsensusVoteStrategy: Execution failed despite consensus for {node_name}: {type(e).__name__}: {str(e)}") + f"ConsensusVoteStrategy: Execution failed despite consensus for {node_name}: {type(e).__name__}: {str(e)}" + ) self.logger.error( - f"ConsensusVoteStrategy: Params that caused failure: {best_vote['modified_params']}") + f"ConsensusVoteStrategy: Params that caused failure: {best_vote['modified_params']}" + ) self.logger.error( - f"ConsensusVoteStrategy: Insufficient confidence ({avg_confidence:.2f} < {self.vote_threshold}) for {node_name}") + f"ConsensusVoteStrategy: Insufficient confidence ({avg_confidence:.2f} < {self.vote_threshold}) for {node_name}" + ) return None class RetryWithAlternatePromptStrategy(RemediationStrategy): """Retry with modified prompt template.""" - def __init__(self, llm_config: Dict[str, Any], alternate_prompts: Optional[List[str]] = None): - super().__init__("retry_with_alternate_prompt", - f"Retry with {len(alternate_prompts) if alternate_prompts else 'default'} alternate prompts") + def __init__( + self, llm_config: Dict[str, Any], alternate_prompts: Optional[List[str]] = None + ): + super().__init__( + "retry_with_alternate_prompt", + f"Retry with {len(alternate_prompts) if alternate_prompts else 'default'} alternate prompts", + ) self.llm_config = llm_config if alternate_prompts is not None and isinstance(alternate_prompts, list): self.alternate_prompts = alternate_prompts @@ -480,7 +524,7 @@ def __init__(self, llm_config: Dict[str, Any], alternate_prompts: Optional[List[ "Try with absolute value: {user_input}", "Try with positive number: {user_input}", "Try with default value: {user_input}", - "Try with zero: {user_input}" + "Try with zero: {user_input}", ] def execute( @@ -491,37 +535,47 @@ def execute( original_error: Optional[ExecutionError] = None, handler_func: Optional[Callable] = None, validated_params: Optional[Dict[str, Any]] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Try different parameter modifications.""" if not handler_func or validated_params is None: self.logger.warning( - f"RetryWithAlternatePromptStrategy: Missing handler_func or validated_params for {node_name}") + f"RetryWithAlternatePromptStrategy: Missing handler_func or validated_params for {node_name}" + ) return None - from intent_kit.services.llm_factory import LLMFactory - llm_client = LLMFactory.create_client(self.llm_config) - # Try different parameter modification strategies modification_strategies = [ # Strategy 1: Try with absolute values for numeric parameters - lambda params: {k: abs(v) if isinstance( - v, (int, float)) else v for k, v in params.items()}, + lambda params: { + k: abs(v) if isinstance(v, (int, float)) else v + for k, v in params.items() + }, # Strategy 2: Try with positive values for numeric parameters - lambda params: {k: max(0, v) if isinstance( - v, (int, float)) else v for k, v in params.items()}, + lambda params: { + k: max(0, v) if isinstance(v, (int, float)) else v + for k, v in params.items() + }, # Strategy 3: Try with default values (1 for numbers, empty string for strings) - lambda params: {k: (1 if isinstance(v, (int, float)) else "") if v is None or ( - isinstance(v, (int, float)) and v < 0) else v for k, v in params.items()}, + lambda params: { + k: ( + (1 if isinstance(v, (int, float)) else "") + if v is None or (isinstance(v, (int, float)) and v < 0) + else v + ) + for k, v in params.items() + }, # Strategy 4: Try with zero for numeric parameters - lambda params: {k: 0 if isinstance( - v, (int, float)) else v for k, v in params.items()} + lambda params: { + k: 0 if isinstance(v, (int, float)) else v for k, v in params.items() + }, ] for i, strategy in enumerate(modification_strategies): try: self.logger.info( - f"RetryWithAlternatePromptStrategy: Trying modification strategy {i + 1}/{len(modification_strategies)} for {node_name}") + f"RetryWithAlternatePromptStrategy: Trying modification strategy {i + 1}/{len(modification_strategies)} for {node_name}" + ) # Apply the modification strategy modified_params = strategy(validated_params) @@ -532,7 +586,8 @@ def execute( output = handler_func(**modified_params) self.logger.info( - f"RetryWithAlternatePromptStrategy: Success with strategy {i + 1} for {node_name}") + f"RetryWithAlternatePromptStrategy: Success with strategy {i + 1} for {node_name}" + ) return ExecutionResult( success=True, @@ -543,15 +598,17 @@ def execute( output=output, error=None, params=modified_params, - children_results=[] + children_results=[], ) except Exception as e: self.logger.warning( - f"RetryWithAlternatePromptStrategy: Strategy {i + 1} failed for {node_name}: {type(e).__name__}: {str(e)}") + f"RetryWithAlternatePromptStrategy: Strategy {i + 1} failed for {node_name}: {type(e).__name__}: {str(e)}" + ) self.logger.error( - f"RetryWithAlternatePromptStrategy: All {len(modification_strategies)} strategies failed for {node_name}") + f"RetryWithAlternatePromptStrategy: All {len(modification_strategies)} strategies failed for {node_name}" + ) return None @@ -599,43 +656,52 @@ def list_remediation_strategies() -> List[str]: return _remediation_registry.list_strategies() -def create_retry_strategy(max_attempts: int = 3, base_delay: float = 1.0) -> RemediationStrategy: +def create_retry_strategy( + max_attempts: int = 3, base_delay: float = 1.0 +) -> RemediationStrategy: """Create a retry strategy with specified parameters.""" - strategy = RetryOnFailStrategy( - max_attempts=max_attempts, base_delay=base_delay) + strategy = RetryOnFailStrategy(max_attempts=max_attempts, base_delay=base_delay) register_remediation_strategy("retry_on_fail", strategy) return strategy -def create_fallback_strategy(fallback_handler: Callable, fallback_name: str = "fallback") -> RemediationStrategy: +def create_fallback_strategy( + fallback_handler: Callable, fallback_name: str = "fallback" +) -> RemediationStrategy: """Create a fallback strategy with specified handler.""" strategy = FallbackToAnotherNodeStrategy(fallback_handler, fallback_name) register_remediation_strategy("fallback_to_another_node", strategy) return strategy -def create_self_reflect_strategy(llm_config: Dict[str, Any], max_reflections: int = 2) -> RemediationStrategy: +def create_self_reflect_strategy( + llm_config: Dict[str, Any], max_reflections: int = 2 +) -> RemediationStrategy: """Create a self-reflection strategy with specified LLM config.""" strategy = SelfReflectStrategy(llm_config, max_reflections) register_remediation_strategy("self_reflect", strategy) return strategy -def create_consensus_vote_strategy(llm_configs: List[Dict[str, Any]], vote_threshold: float = 0.6) -> RemediationStrategy: +def create_consensus_vote_strategy( + llm_configs: List[Dict[str, Any]], vote_threshold: float = 0.6 +) -> RemediationStrategy: """Create a consensus voting strategy with multiple LLM configs.""" strategy = ConsensusVoteStrategy(llm_configs, vote_threshold) register_remediation_strategy("consensus_vote", strategy) return strategy -def create_alternate_prompt_strategy(llm_config: Dict[str, Any], alternate_prompts: Optional[List[str]] = None) -> RemediationStrategy: +def create_alternate_prompt_strategy( + llm_config: Dict[str, Any], alternate_prompts: Optional[List[str]] = None +) -> RemediationStrategy: """Create an alternate prompt strategy with specified prompts.""" if alternate_prompts is None: alternate_prompts = [ "Please try a different approach: {user_input}", "Consider this alternative perspective: {user_input}", "Let's approach this step by step: {user_input}", - "Think about this from a different angle: {user_input}" + "Think about this from a different angle: {user_input}", ] strategy = RetryWithAlternatePromptStrategy(llm_config, alternate_prompts) register_remediation_strategy("retry_with_alternate_prompt", strategy) @@ -645,13 +711,16 @@ def create_alternate_prompt_strategy(llm_config: Dict[str, Any], alternate_promp # Initialize built-in strategies create_retry_strategy() create_fallback_strategy( - lambda **kwargs: "Fallback handler executed", "default_fallback") + lambda **kwargs: "Fallback handler executed", "default_fallback" +) class ClassifierFallbackStrategy(RemediationStrategy): """Fallback strategy for classifiers that tries alternative classification methods.""" - def __init__(self, fallback_classifier: Callable, fallback_name: str = "fallback_classifier"): + def __init__( + self, fallback_classifier: Callable, fallback_name: str = "fallback_classifier" + ): super().__init__("classifier_fallback", f"Fallback to {fallback_name}") self.fallback_classifier = fallback_classifier self.fallback_name = fallback_name @@ -664,29 +733,33 @@ def execute( original_error: Optional[ExecutionError] = None, classifier_func: Optional[Callable] = None, available_children: Optional[List] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Execute the fallback classifier.""" try: self.logger.info( - f"ClassifierFallbackStrategy: Executing {self.fallback_name} for {node_name}") + f"ClassifierFallbackStrategy: Executing {self.fallback_name} for {node_name}" + ) if not available_children: self.logger.warning( - f"ClassifierFallbackStrategy: No available children for {node_name}") + f"ClassifierFallbackStrategy: No available children for {node_name}" + ) return None # Try the fallback classifier - context_dict = {} + context_dict: dict = {} if context: context_dict = {} chosen = self.fallback_classifier( - user_input, available_children, context_dict) + user_input, available_children, context_dict + ) if not chosen: self.logger.warning( - f"ClassifierFallbackStrategy: Fallback classifier failed for {node_name}") + f"ClassifierFallbackStrategy: Fallback classifier failed for {node_name}" + ) return None # Execute the chosen child @@ -703,9 +776,9 @@ def execute( params={ "chosen_child": chosen.name, "available_children": [child.name for child in available_children], - "remediation_strategy": self.name + "remediation_strategy": self.name, }, - children_results=[child_result] + children_results=[child_result], ) except Exception as e: @@ -729,16 +802,18 @@ def execute( original_error: Optional[ExecutionError] = None, classifier_func: Optional[Callable] = None, available_children: Optional[List] = None, - **kwargs + **kwargs, ) -> Optional[ExecutionResult]: """Use keyword matching as fallback classification.""" try: self.logger.info( - f"KeywordFallbackStrategy: Using keyword fallback for {node_name}") + f"KeywordFallbackStrategy: Using keyword fallback for {node_name}" + ) if not available_children: self.logger.warning( - f"KeywordFallbackStrategy: No available children for {node_name}") + f"KeywordFallbackStrategy: No available children for {node_name}" + ) return None user_input_lower = user_input.lower() @@ -748,7 +823,8 @@ def execute( # Check handler name if child.name and child.name.lower() in user_input_lower: self.logger.info( - f"KeywordFallbackStrategy: Matched '{child.name}' by name for {node_name}") + f"KeywordFallbackStrategy: Matched '{child.name}' by name for {node_name}" + ) child_result = child.execute(user_input, context) return ExecutionResult( success=True, @@ -762,22 +838,27 @@ def execute( "chosen_child": child.name, "available_children": [c.name for c in available_children], "remediation_strategy": self.name, - "match_type": "name" + "match_type": "name", }, - children_results=[child_result] + children_results=[child_result], ) # Check description keywords if child.description: desc_lower = child.description.lower() # Extract meaningful words from description - desc_words = [word for word in desc_lower.split() - if len(word) > 3 and word not in ['the', 'and', 'for', 'with', 'this', 'that']] + desc_words = [ + word + for word in desc_lower.split() + if len(word) > 3 + and word not in ["the", "and", "for", "with", "this", "that"] + ] for word in desc_words: if word in user_input_lower: self.logger.info( - f"KeywordFallbackStrategy: Matched '{child.name}' by description keyword '{word}' for {node_name}") + f"KeywordFallbackStrategy: Matched '{child.name}' by description keyword '{word}' for {node_name}" + ) child_result = child.execute(user_input, context) return ExecutionResult( success=True, @@ -789,16 +870,19 @@ def execute( error=None, params={ "chosen_child": child.name, - "available_children": [c.name for c in available_children], + "available_children": [ + c.name for c in available_children + ], "remediation_strategy": self.name, "match_type": "description", - "matched_keyword": word + "matched_keyword": word, }, - children_results=[child_result] + children_results=[child_result], ) self.logger.warning( - f"KeywordFallbackStrategy: No keyword match found for {node_name}") + f"KeywordFallbackStrategy: No keyword match found for {node_name}" + ) return None except Exception as e: @@ -808,7 +892,9 @@ def execute( return None -def create_classifier_fallback_strategy(fallback_classifier: Callable, fallback_name: str = "fallback_classifier") -> RemediationStrategy: +def create_classifier_fallback_strategy( + fallback_classifier: Callable, fallback_name: str = "fallback_classifier" +) -> RemediationStrategy: """Create a classifier fallback strategy with specified classifier.""" strategy = ClassifierFallbackStrategy(fallback_classifier, fallback_name) register_remediation_strategy("classifier_fallback", strategy) diff --git a/intent_kit/node/base.py b/intent_kit/node/base.py index 7527c97..b54b9b9 100644 --- a/intent_kit/node/base.py +++ b/intent_kit/node/base.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, Callable, List, Optional, Dict, Type, Set, Sequence +from typing import List, Optional from abc import ABC, abstractmethod from intent_kit.utils.logger import Logger from intent_kit.context import IntentContext @@ -21,7 +21,7 @@ def has_name(self) -> bool: def get_path(self) -> List[str]: path = [] - node = self + node: Optional["Node"] = self while node: path.append(node.name) node = node.parent @@ -32,7 +32,7 @@ def get_path_string(self) -> str: def get_uuid_path(self) -> List[str]: path = [] - node = self + node: Optional["Node"] = self while node: path.append(node.node_id) node = node.parent @@ -45,7 +45,14 @@ def get_uuid_path_string(self) -> str: class TreeNode(Node, ABC): """Base class for all nodes in the intent tree.""" - def __init__(self, *, name: Optional[str] = None, description: str, children: Optional[List["TreeNode"]] = None, parent: Optional["TreeNode"] = None): + def __init__( + self, + *, + name: Optional[str] = None, + description: str, + children: Optional[List["TreeNode"]] = None, + parent: Optional["TreeNode"] = None, + ): super().__init__(name=name, parent=parent) self.logger = Logger(name or "unnamed_node") self.description = description @@ -59,6 +66,8 @@ def node_type(self) -> NodeType: return NodeType.UNKNOWN @abstractmethod - def execute(self, user_input: str, context: Optional[IntentContext] = None) -> ExecutionResult: + def execute( + self, user_input: str, context: Optional[IntentContext] = None + ) -> ExecutionResult: """Execute the node with the given user input and optional context.""" pass diff --git a/intent_kit/node/enums.py b/intent_kit/node/enums.py index d9d4f87..6a0c746 100644 --- a/intent_kit/node/enums.py +++ b/intent_kit/node/enums.py @@ -1,6 +1,7 @@ """ Enums for the node system. """ + from enum import Enum diff --git a/intent_kit/node/types.py b/intent_kit/node/types.py index 9586a56..419ec53 100644 --- a/intent_kit/node/types.py +++ b/intent_kit/node/types.py @@ -1,6 +1,7 @@ """ Data classes and types for the node system. """ + from dataclasses import dataclass from typing import Any, Dict, List, Optional from intent_kit.node.enums import NodeType @@ -9,6 +10,7 @@ @dataclass class ExecutionError: """Structured error information for execution results.""" + error_type: str message: str node_name: str @@ -20,27 +22,32 @@ class ExecutionError: original_exception: Optional[Exception] = None @classmethod - def from_exception(cls, exception: Exception, node_name: str, node_path: List[str], - node_id: Optional[str] = None) -> "ExecutionError": + def from_exception( + cls, + exception: Exception, + node_name: str, + node_path: List[str], + node_id: Optional[str] = None, + ) -> "ExecutionError": """Create an ExecutionError from an exception.""" - if hasattr(exception, 'validation_error'): + if hasattr(exception, "validation_error"): return cls( error_type=type(exception).__name__, - message=getattr(exception, 'validation_error', str(exception)), + message=getattr(exception, "validation_error", str(exception)), node_name=node_name, node_path=node_path, node_id=node_id, - input_data=getattr(exception, 'input_data', None), - params=getattr(exception, 'input_data', None) + input_data=getattr(exception, "input_data", None), + params=getattr(exception, "input_data", None), ) - elif hasattr(exception, 'error_message'): + elif hasattr(exception, "error_message"): return cls( error_type=type(exception).__name__, - message=getattr(exception, 'error_message', str(exception)), + message=getattr(exception, "error_message", str(exception)), node_name=node_name, node_path=node_path, node_id=node_id, - params=getattr(exception, 'params', None) + params=getattr(exception, "params", None), ) else: return cls( @@ -49,7 +56,7 @@ def from_exception(cls, exception: Exception, node_name: str, node_path: List[st node_name=node_name, node_path=node_path, node_id=node_id, - original_exception=exception + original_exception=exception, ) def to_dict(self) -> Dict[str, Any]: @@ -62,13 +69,14 @@ def to_dict(self) -> Dict[str, Any]: "node_id": self.node_id, "input_data": self.input_data, "output_data": self.output_data, - "params": self.params + "params": self.params, } @dataclass class ExecutionResult: """Standardized execution result structure for all nodes.""" + success: bool node_name: str node_path: List[str] diff --git a/intent_kit/services/__init__.py b/intent_kit/services/__init__.py index 9ebd43f..86ecd7e 100644 --- a/intent_kit/services/__init__.py +++ b/intent_kit/services/__init__.py @@ -1,6 +1,6 @@ """Service registry for intentify with optional dependencies.""" -from typing import Dict, Type, Optional, Any +from typing import Dict, Type, Any from .google_client import GoogleClient from .anthropic_client import AnthropicClient from .openai_client import OpenAIClient @@ -11,10 +11,10 @@ class ServiceRegistry: """Registry for AI services with optional dependencies.""" _services: Dict[str, Type] = { - 'google': GoogleClient, - 'anthropic': AnthropicClient, - 'openai': OpenAIClient, - 'ollama': OllamaClient, + "google": GoogleClient, + "anthropic": AnthropicClient, + "openai": OpenAIClient, + "ollama": OllamaClient, } @classmethod @@ -22,7 +22,7 @@ def get_available_services(cls) -> Dict[str, bool]: """Get a dictionary of service names and their availability.""" available = {} for name, service_class in cls._services.items(): - if hasattr(service_class, 'is_available'): + if hasattr(service_class, "is_available"): available[name] = service_class.is_available() else: available[name] = True # Assume available if no check method @@ -36,7 +36,7 @@ def create_service(cls, service_name: str, **kwargs) -> Any: service_class = cls._services[service_name] - if hasattr(service_class, 'is_available') and not service_class.is_available(): + if hasattr(service_class, "is_available") and not service_class.is_available(): raise ImportError( f"Service '{service_name}' is not available. " f"Install required dependencies: pip install intentify[openai]" @@ -49,6 +49,7 @@ def register_service(cls, name: str, service_class: Type): """Register a new service class.""" cls._services[name] = service_class + # Convenience functions diff --git a/intent_kit/services/anthropic_client.py b/intent_kit/services/anthropic_client.py index 7c58f2e..f030c41 100644 --- a/intent_kit/services/anthropic_client.py +++ b/intent_kit/services/anthropic_client.py @@ -15,6 +15,7 @@ def get_client(self): """Get the Anthropic client.""" try: import anthropic + return anthropic.Anthropic(api_key=self.api_key) except ImportError: raise ImportError( @@ -26,6 +27,7 @@ def _ensure_imported(self): if self._client is None: try: import anthropic + self._client = anthropic.Anthropic(api_key=self.api_key) except ImportError: raise ImportError( @@ -36,15 +38,15 @@ def generate(self, prompt: str, model: str = "claude-3-sonnet-20240229") -> str: """Generate text using Anthropic's Claude model.""" self._ensure_imported() response = self._client.messages.create( - model=model, - max_tokens=1000, - messages=[{"role": "user", "content": prompt}] + model=model, max_tokens=1000, messages=[{"role": "user", "content": prompt}] ) content = response.content logger.debug(f"Anthropic generate response: {content}") return str(content) if content else "" # Keep generate_text as an alias for backward compatibility - def generate_text(self, prompt: str, model: str = "claude-3-sonnet-20240229") -> str: + def generate_text( + self, prompt: str, model: str = "claude-3-sonnet-20240229" + ) -> str: """Alias for generate method (backward compatibility).""" return self.generate(prompt, model) diff --git a/intent_kit/services/google_client.py b/intent_kit/services/google_client.py index 5077f47..3b59cd9 100644 --- a/intent_kit/services/google_client.py +++ b/intent_kit/services/google_client.py @@ -15,6 +15,7 @@ def get_client(self): """Get the Google GenAI client.""" try: from google import genai + return genai.Client(api_key=self.api_key) except ImportError: raise ImportError( @@ -26,6 +27,7 @@ def _ensure_imported(self): if self._client is None: try: from google import genai + self._client = genai.Client(api_key=self.api_key) except ImportError: raise ImportError( diff --git a/intent_kit/services/llm_factory.py b/intent_kit/services/llm_factory.py index 646b883..9c883e1 100644 --- a/intent_kit/services/llm_factory.py +++ b/intent_kit/services/llm_factory.py @@ -4,7 +4,7 @@ This module provides a factory for creating LLM clients based on provider configuration. """ -from typing import Dict, Any, Optional +from typing import Dict, Any from intent_kit.services.openai_client import OpenAIClient from intent_kit.services.anthropic_client import AnthropicClient from intent_kit.services.google_client import GoogleClient @@ -57,7 +57,8 @@ def create_client(llm_config: Dict[str, Any]): # For other providers, API key is required if not api_key: raise ValueError( - "LLM config must include 'api_key' for provider: {provider}") + "LLM config must include 'api_key' for provider: {provider}" + ) if provider == "openai": return OpenAIClient(api_key=api_key) @@ -86,8 +87,6 @@ def generate_with_config(llm_config: Dict[str, Any], prompt: str) -> str: # Extract optional parameters model = llm_config.get("model") - max_tokens = llm_config.get("max_tokens") - temperature = llm_config.get("temperature") # For now, we'll use the default generate method # In the future, we can extend this to pass additional parameters diff --git a/intent_kit/services/ollama_client.py b/intent_kit/services/ollama_client.py index 1a82fba..65f7c84 100644 --- a/intent_kit/services/ollama_client.py +++ b/intent_kit/services/ollama_client.py @@ -15,6 +15,7 @@ def get_client(self): """Get the Ollama client.""" try: from ollama import Client + return Client(host=self.base_url) except ImportError: raise ImportError( @@ -26,6 +27,7 @@ def _ensure_imported(self): if self._client is None: try: from ollama import Client + self._client = Client(host=self.base_url) except ImportError: raise ImportError( @@ -36,11 +38,8 @@ def generate(self, prompt: str, model: str = "llama2") -> str: """Generate text using Ollama model.""" self._ensure_imported() try: - response = self._client.generate( - model=model, - prompt=prompt - ) - content = response['response'] + response = self._client.generate(model=model, prompt=prompt) + content = response["response"] logger.debug(f"Ollama generate response: {content}") return str(content) if content else "" except Exception as e: @@ -51,12 +50,8 @@ def generate_stream(self, prompt: str, model: str = "llama2"): """Generate text using Ollama model with streaming.""" self._ensure_imported() try: - for chunk in self._client.generate( - model=model, - prompt=prompt, - stream=True - ): - yield chunk['response'] + for chunk in self._client.generate(model=model, prompt=prompt, stream=True): + yield chunk["response"] except Exception as e: logger.error(f"Error streaming with Ollama: {e}") raise @@ -65,11 +60,8 @@ def chat(self, messages: list, model: str = "llama2") -> str: """Chat with Ollama model using messages format.""" self._ensure_imported() try: - response = self._client.chat( - model=model, - messages=messages - ) - content = response['message']['content'] + response = self._client.chat(model=model, messages=messages) + content = response["message"]["content"] logger.debug(f"Ollama chat response: {content}") return str(content) if content else "" except Exception as e: @@ -80,12 +72,8 @@ def chat_stream(self, messages: list, model: str = "llama2"): """Chat with Ollama model using messages format with streaming.""" self._ensure_imported() try: - for chunk in self._client.chat( - model=model, - messages=messages, - stream=True - ): - yield chunk['message']['content'] + for chunk in self._client.chat(model=model, messages=messages, stream=True): + yield chunk["message"]["content"] except Exception as e: logger.error(f"Error streaming chat with Ollama: {e}") raise @@ -98,20 +86,19 @@ def list_models(self): logger.debug(f"Ollama list response: {models_response}") # The correct type is ListResponse, which has a .models attribute - if hasattr(models_response, 'models'): + if hasattr(models_response, "models"): models = models_response.models else: - logger.error( - f"Unexpected response structure: {models_response}") + logger.error(f"Unexpected response structure: {models_response}") return [] # Each model is a ListResponse.Model with a .model attribute model_names = [] for model in models: - if hasattr(model, 'model') and model.model: + if hasattr(model, "model") and model.model: model_names.append(model.model) - elif isinstance(model, dict) and 'model' in model: - model_names.append(model['model']) + elif isinstance(model, dict) and "model" in model: + model_names.append(model["model"]) elif isinstance(model, str): model_names.append(model) else: @@ -152,7 +139,8 @@ def generate_text(self, prompt: str, model: str = "llama2") -> str: def is_available(cls) -> bool: """Check if Ollama package is available.""" try: - from ollama import Client - return True + import importlib.util + + return importlib.util.find_spec("ollama") is not None except ImportError: return False diff --git a/intent_kit/services/openai_client.py b/intent_kit/services/openai_client.py index 66bbff6..2f2984e 100644 --- a/intent_kit/services/openai_client.py +++ b/intent_kit/services/openai_client.py @@ -15,6 +15,7 @@ def get_client(self): """Get the OpenAI client.""" try: import openai + return openai.OpenAI(api_key=self.api_key) except ImportError: raise ImportError( @@ -26,6 +27,7 @@ def _ensure_imported(self): if self._client is None: try: import openai + self._client = openai.OpenAI(api_key=self.api_key) except ImportError: raise ImportError( @@ -36,9 +38,7 @@ def generate(self, prompt: str, model: str = "gpt-4") -> str: """Generate text using OpenAI's GPT model.""" self._ensure_imported() response = self._client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": prompt}], - max_tokens=1000 + model=model, messages=[{"role": "user", "content": prompt}], max_tokens=1000 ) content = response.choices[0].message.content return str(content) if content else "" diff --git a/intent_kit/services/openrouter_client.py b/intent_kit/services/openrouter_client.py index 6c7aca8..17a3b4f 100644 --- a/intent_kit/services/openrouter_client.py +++ b/intent_kit/services/openrouter_client.py @@ -15,9 +15,9 @@ def get_client(self): """Get the OpenRouter client.""" try: import openai + return openai.OpenAI( - api_key=self.api_key, - base_url="https://openrouter.ai/api/v1" + api_key=self.api_key, base_url="https://openrouter.ai/api/v1" ) except ImportError: raise ImportError( @@ -29,9 +29,9 @@ def _ensure_imported(self): if self._client is None: try: import openai + self._client = openai.OpenAI( - api_key=self.api_key, - base_url="https://openrouter.ai/api/v1" + api_key=self.api_key, base_url="https://openrouter.ai/api/v1" ) except ImportError: raise ImportError( @@ -42,9 +42,7 @@ def generate(self, prompt: str, model: str = "openai/gpt-4") -> str: """Generate text using OpenRouter model.""" self._ensure_imported() response = self._client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": prompt}], - max_tokens=1000 + model=model, messages=[{"role": "user", "content": prompt}], max_tokens=1000 ) content = response.choices[0].message.content logger.debug(f"OpenRouter generate response: {content}") diff --git a/intent_kit/splitters/__init__.py b/intent_kit/splitters/__init__.py index 42bca23..87bf3f6 100644 --- a/intent_kit/splitters/__init__.py +++ b/intent_kit/splitters/__init__.py @@ -12,7 +12,6 @@ __all__ = [ # Node class "SplitterNode", - # Splitter functions "rule_splitter", "llm_splitter", diff --git a/intent_kit/splitters/llm_splitter.py b/intent_kit/splitters/llm_splitter.py index 55b79ac..142d2e4 100644 --- a/intent_kit/splitters/llm_splitter.py +++ b/intent_kit/splitters/llm_splitter.py @@ -1,15 +1,16 @@ """ LLM-based intent splitter for IntentGraph. """ -from typing import Dict, List, Any, Optional, Sequence -import re -import json + +from typing import List, Sequence from intent_kit.utils.logger import Logger from intent_kit.types import IntentChunk from intent_kit.utils.text_utils import extract_json_array_from_text -def llm_splitter(user_input: str, debug: bool = False, llm_client=None) -> Sequence[IntentChunk]: +def llm_splitter( + user_input: str, debug: bool = False, llm_client=None +) -> Sequence[IntentChunk]: """ LLM-based intent splitter using AI models. @@ -29,9 +30,11 @@ def llm_splitter(user_input: str, debug: bool = False, llm_client=None) -> Seque if not llm_client: if debug: logger.warning( - "No LLM client available, falling back to rule-based splitting") + "No LLM client available, falling back to rule-based splitting" + ) # Fallback to rule-based splitting from .rule_splitter import rule_splitter + return rule_splitter(user_input, debug) try: @@ -60,17 +63,19 @@ def llm_splitter(user_input: str, debug: bool = False, llm_client=None) -> Seque # If no valid results, fallback to rule-based if debug: logger.warning( - "LLM parsing returned no results, falling back to rule-based") + "LLM parsing returned no results, falling back to rule-based" + ) from .rule_splitter import rule_splitter + return rule_splitter(user_input, debug) except Exception as e: if debug: - logger.error( - f"LLM splitting failed: {e}, falling back to rule-based") + logger.error(f"LLM splitting failed: {e}, falling back to rule-based") # Fallback to rule-based splitting from .rule_splitter import rule_splitter + return rule_splitter(user_input, debug) @@ -105,8 +110,7 @@ def _parse_llm_response(response: str) -> List[str]: results.append(item.strip()) return results # If JSON parsing fails, try manual extraction from the utility - manual = extract_json_array_from_text( - response, fallback_to_manual=True) + manual = extract_json_array_from_text(response, fallback_to_manual=True) if manual: return [str(item).strip() for item in manual] return [] diff --git a/intent_kit/splitters/node.py b/intent_kit/splitters/node.py index 5a678a6..7981b38 100644 --- a/intent_kit/splitters/node.py +++ b/intent_kit/splitters/node.py @@ -1,10 +1,8 @@ -from typing import Any, List, Optional +from typing import List, Optional from intent_kit.node.base import TreeNode from intent_kit.node.enums import NodeType -from intent_kit.utils.logger import Logger from intent_kit.context import IntentContext from intent_kit.node.types import ExecutionResult, ExecutionError -from intent_kit.types import IntentChunk class SplitterNode(TreeNode): @@ -17,9 +15,11 @@ def __init__( children: List["TreeNode"], description: str = "", parent: Optional["TreeNode"] = None, - llm_client=None + llm_client=None, ): - super().__init__(name=name, description=description, children=children, parent=parent) + super().__init__( + name=name, description=description, children=children, parent=parent + ) self.splitter_function = splitter_function self.llm_client = llm_client @@ -28,12 +28,13 @@ def node_type(self) -> NodeType: """Get the type of this node.""" return NodeType.SPLITTER - def execute(self, user_input: str, context: Optional[IntentContext] = None) -> ExecutionResult: + def execute( + self, user_input: str, context: Optional[IntentContext] = None + ) -> ExecutionResult: try: intent_chunks = self.splitter_function(user_input, debug=False) if not intent_chunks: - self.logger.warning( - f"Splitter '{self.name}' found no intent chunks") + self.logger.warning(f"Splitter '{self.name}' found no intent chunks") return ExecutionResult( success=False, node_name=self.name, @@ -45,13 +46,14 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type="NoIntentChunksFound", message="No intent chunks found after splitting", node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params={"intent_chunks": []}, - children_results=[] + children_results=[], ) self.logger.debug( - f"Splitter '{self.name}' found {len(intent_chunks)} chunks: {intent_chunks}") + f"Splitter '{self.name}' found {len(intent_chunks)} chunks: {intent_chunks}" + ) children_results = [] all_outputs = [] for chunk in intent_chunks: @@ -70,14 +72,15 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E break except Exception as e: self.logger.debug( - f"Child '{child.name}' failed to handle chunk '{chunk_text}': {e}") + f"Child '{child.name}' failed to handle chunk '{chunk_text}': {e}" + ) continue if not handled: error_result = ExecutionResult( success=False, node_name=f"unhandled_chunk_{chunk_text[:20]}", - node_path=self.get_path() + - [f"unhandled_chunk_{chunk_text[:20]}"], + node_path=self.get_path() + + [f"unhandled_chunk_{chunk_text[:20]}"], node_type=NodeType.UNHANDLED_CHUNK, input=chunk_text, output=None, @@ -85,10 +88,10 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E error_type="UnhandledChunk", message=f"No child node could handle chunk: '{chunk_text}'", node_name=self.name, - node_path=self.get_path() + node_path=self.get_path(), ), params={"chunk": chunk_text}, - children_results=[] + children_results=[], ) children_results.append(error_result) successful_results = [r for r in children_results if r.success] @@ -104,13 +107,12 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E params={ "intent_chunks": intent_chunks, "chunks_processed": len(intent_chunks), - "chunks_handled": len(successful_results) + "chunks_handled": len(successful_results), }, - children_results=children_results + children_results=children_results, ) except Exception as e: - self.logger.error( - f"Splitter execution error for '{self.name}': {e}") + self.logger.error(f"Splitter execution error for '{self.name}': {e}") return ExecutionResult( success=False, node_name=self.name, @@ -119,7 +121,8 @@ def execute(self, user_input: str, context: Optional[IntentContext] = None) -> E input=user_input, output=None, error=ExecutionError.from_exception( - e, self.name, self.get_path(), node_id=self.node_id), + e, self.name, self.get_path(), node_id=self.node_id + ), params=None, - children_results=[] + children_results=[], ) diff --git a/intent_kit/splitters/rule_splitter.py b/intent_kit/splitters/rule_splitter.py index 00f6e79..2b5d53d 100644 --- a/intent_kit/splitters/rule_splitter.py +++ b/intent_kit/splitters/rule_splitter.py @@ -1,7 +1,8 @@ """ Rule-based intent splitter for IntentGraph. """ -from typing import Dict, List, Optional, Any + +from typing import List import re from intent_kit.utils.logger import Logger from intent_kit.types import IntentChunk @@ -26,12 +27,10 @@ def rule_splitter(user_input: str, debug: bool = False) -> List[IntentChunk]: # Separate word and punctuation conjunctions for regex word_conjunctions = ["and", "also", "plus", "as well as"] punct_conjunctions = [",", ";"] - conjunctions = word_conjunctions + punct_conjunctions # Build regex pattern for conjunctions # For word conjunctions, use word boundaries - word_pattern = r"|".join( - [fr"\b{re.escape(conj)}\b" for conj in word_conjunctions]) + word_pattern = r"|".join([rf"\b{re.escape(conj)}\b" for conj in word_conjunctions]) # For punctuation, just escape them punct_pattern = r"|".join([re.escape(conj) for conj in punct_conjunctions]) diff --git a/intent_kit/splitters/types.py b/intent_kit/splitters/types.py index 2befae3..480d388 100644 --- a/intent_kit/splitters/types.py +++ b/intent_kit/splitters/types.py @@ -1,6 +1,7 @@ """ Splitter types - re-exported from central types module. """ + from intent_kit.types import ( IntentChunk, IntentChunkClassification, @@ -8,7 +9,7 @@ IntentAction, ClassifierOutput, SplitterFunction, - ClassifierFunction + ClassifierFunction, ) __all__ = [ @@ -18,5 +19,5 @@ "IntentAction", "ClassifierOutput", "SplitterFunction", - "ClassifierFunction" + "ClassifierFunction", ] diff --git a/intent_kit/types.py b/intent_kit/types.py index 5d52c84..2f054e1 100644 --- a/intent_kit/types.py +++ b/intent_kit/types.py @@ -1,6 +1,7 @@ """ Core types for intent-kit package. """ + from typing import TypedDict, Optional, Dict, Any, Sequence, Union, Callable from enum import Enum @@ -37,11 +38,8 @@ class IntentChunkClassification(TypedDict, total=False): SplitterFunction = Callable[ [str, bool], # Required args: user_input, debug # Return type: sequence of strings or dicts with text and metadata - Sequence[IntentChunk] + Sequence[IntentChunk], ] # Classifier function type -ClassifierFunction = Callable[ - [IntentChunk], - ClassifierOutput -] +ClassifierFunction = Callable[[IntentChunk], ClassifierOutput] diff --git a/intent_kit/utils/logger.py b/intent_kit/utils/logger.py index 26fdb16..7ca479a 100644 --- a/intent_kit/utils/logger.py +++ b/intent_kit/utils/logger.py @@ -22,7 +22,7 @@ def get_color(self, level): return "\033[90m" # gray # New soft color palette using 256-color ANSI codes elif level == "section_title": - return "\033[38;5;75m" # Soft blue/cyan for main section titles + return "\033[38;5;75m" # Soft blue/cyan for main section titles elif level == "field_label": # Muted light purple for field names/labels return "\033[38;5;146m" @@ -69,16 +69,16 @@ def supports_color(self): import sys # Check if we're in a terminal - if not hasattr(sys.stdout, 'isatty') or not sys.stdout.isatty(): + if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty(): return False # Check environment variables - if os.environ.get('NO_COLOR'): + if os.environ.get("NO_COLOR"): return False # Check if we're in a dumb terminal - term = os.environ.get('TERM', '') - if term == 'dumb': + term = os.environ.get("TERM", "") + if term == "dumb": return False return True @@ -127,7 +127,7 @@ def colorize_bright(self, text, color_name): "red": "bright_red", "magenta": "bright_magenta", "cyan": "bright_cyan", - "white": "bright_white" + "white": "bright_white", } color = bright_colors.get(color_name, color_name) return self.colorize(text, color) diff --git a/intent_kit/utils/text_utils.py b/intent_kit/utils/text_utils.py index bb62a4a..c24870a 100644 --- a/intent_kit/utils/text_utils.py +++ b/intent_kit/utils/text_utils.py @@ -7,13 +7,15 @@ import json import re -from typing import Any, Dict, List, Optional, Union, Tuple +from typing import Any, Dict, List, Optional, Tuple from intent_kit.utils.logger import Logger logger = Logger(__name__) -def extract_json_from_text(text: Optional[str], fallback_to_manual: bool = True) -> Optional[Dict[str, Any]]: +def extract_json_from_text( + text: Optional[str], fallback_to_manual: bool = True +) -> Optional[Dict[str, Any]]: """ Extract JSON object from text, handling various formats and edge cases. Now also supports extracting from ```json ... ``` blocks. @@ -22,7 +24,7 @@ def extract_json_from_text(text: Optional[str], fallback_to_manual: bool = True) return None # First, look for a ```json ... ``` block - json_block = re.search(r'```json\s*([\s\S]*?)```', text, re.IGNORECASE) + json_block = re.search(r"```json\s*([\s\S]*?)```", text, re.IGNORECASE) if json_block: json_str = json_block.group(1).strip() try: @@ -31,7 +33,7 @@ def extract_json_from_text(text: Optional[str], fallback_to_manual: bool = True) logger.debug(f"JSON decode error in ```json block: {e}") # Try to find JSON object pattern - json_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, re.DOTALL) + json_match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL) if json_match: json_str = json_match.group(0) try: @@ -40,8 +42,7 @@ def extract_json_from_text(text: Optional[str], fallback_to_manual: bool = True) logger.debug(f"JSON decode error: {e}") # Try to find JSON array pattern - array_match = re.search( - r'\[[^\[\]]*(?:\{[^{}]*\}[^\[\]]*)*\]', text, re.DOTALL) + array_match = re.search(r"\[[^\[\]]*(?:\{[^{}]*\}[^\[\]]*)*\]", text, re.DOTALL) if array_match: json_str = array_match.group(0) try: @@ -55,7 +56,9 @@ def extract_json_from_text(text: Optional[str], fallback_to_manual: bool = True) return None -def extract_json_array_from_text(text: Optional[str], fallback_to_manual: bool = True) -> Optional[List[Any]]: +def extract_json_array_from_text( + text: Optional[str], fallback_to_manual: bool = True +) -> Optional[List[Any]]: """ Extract JSON array from text, handling various formats and edge cases. Now also supports extracting from ```json ... ``` blocks. @@ -64,7 +67,7 @@ def extract_json_array_from_text(text: Optional[str], fallback_to_manual: bool = return None # First, look for a ```json ... ``` block - json_block = re.search(r'```json\s*([\s\S]*?)```', text, re.IGNORECASE) + json_block = re.search(r"```json\s*([\s\S]*?)```", text, re.IGNORECASE) if json_block: json_str = json_block.group(1).strip() try: @@ -75,8 +78,7 @@ def extract_json_array_from_text(text: Optional[str], fallback_to_manual: bool = logger.debug(f"JSON array decode error in ```json block: {e}") # Try to find JSON array pattern - array_match = re.search( - r'\[[^\[\]]*(?:\{[^{}]*\}[^\[\]]*)*\]', text, re.DOTALL) + array_match = re.search(r"\[[^\[\]]*(?:\{[^{}]*\}[^\[\]]*)*\]", text, re.DOTALL) if array_match: json_str = array_match.group(0) try: @@ -113,13 +115,13 @@ def extract_key_value_pairs(text: Optional[str]) -> Dict[str, Any]: pairs[key.strip()] = _clean_value(value.strip()) # Pattern 2: key: value - kv_pattern2 = re.findall(r'(\w+)\s*:\s*([^,\n}]+)', text) + kv_pattern2 = re.findall(r"(\w+)\s*:\s*([^,\n}]+)", text) for key, value in kv_pattern2: if key not in pairs: # Don't override quoted keys pairs[key.strip()] = _clean_value(value.strip()) # Pattern 3: key = value - kv_pattern3 = re.findall(r'(\w+)\s*=\s*([^,\n}]+)', text) + kv_pattern3 = re.findall(r"(\w+)\s*=\s*([^,\n}]+)", text) for key, value in kv_pattern3: if key not in pairs: pairs[key.strip()] = _clean_value(value.strip()) @@ -161,27 +163,31 @@ def clean_for_deserialization(text: Optional[str]) -> str: return "" # Remove common LLM response artifacts - text = re.sub(r'```json\s*', '', text) - text = re.sub(r'```\s*$', '', text) - text = re.sub(r'^```\s*', '', text) + text = re.sub(r"```json\s*", "", text) + text = re.sub(r"```\s*$", "", text) + text = re.sub(r"^```\s*", "", text) # Fix common JSON issues - text = re.sub(r'([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', - r'\1"\2":', text) # Quote unquoted keys - text = re.sub(r':\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*([,}])', - r': "\1"\2', text) # Quote unquoted string values + text = re.sub( + r"([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', text + ) # Quote unquoted keys + text = re.sub( + r":\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*([,}])", r': "\1"\2', text + ) # Quote unquoted string values # Normalize spacing around colons - text = re.sub(r':\s+', ': ', text) + text = re.sub(r":\s+", ": ", text) # Fix trailing commas - text = re.sub(r',\s*}', '}', text) - text = re.sub(r',\s*]', ']', text) + text = re.sub(r",\s*}", "}", text) + text = re.sub(r",\s*]", "]", text) return text.strip() -def extract_structured_data(text: Optional[str], expected_type: str = "auto") -> Tuple[Optional[Any], str]: +def extract_structured_data( + text: Optional[str], expected_type: str = "auto" +) -> Tuple[Optional[Any], str]: """ Extract structured data from text with type detection. @@ -198,14 +204,13 @@ def extract_structured_data(text: Optional[str], expected_type: str = "auto") -> # For auto detection, try to determine the type first if expected_type == "auto": # Check if it looks like a JSON array - if text.strip().startswith('[') and text.strip().endswith(']'): - json_array = extract_json_array_from_text( - text, fallback_to_manual=False) + if text.strip().startswith("[") and text.strip().endswith("]"): + json_array = extract_json_array_from_text(text, fallback_to_manual=False) if json_array: return json_array, "json_array" # Check if it looks like a JSON object - if text.strip().startswith('{') and text.strip().endswith('}'): + if text.strip().startswith("{") and text.strip().endswith("}"): json_obj = extract_json_from_text(text, fallback_to_manual=False) if json_obj: return json_obj, "json_object" @@ -218,8 +223,7 @@ def extract_structured_data(text: Optional[str], expected_type: str = "auto") -> # Try JSON array if expected_type in ["auto", "list"]: - json_array = extract_json_array_from_text( - text, fallback_to_manual=False) + json_array = extract_json_array_from_text(text, fallback_to_manual=False) if json_array: return json_array, "json_array" @@ -247,7 +251,7 @@ def _manual_json_extraction(text: str) -> Optional[Dict[str, Any]]: """Manually extract JSON-like object from text.""" # Try to extract from common patterns first # Pattern: { key: value, key2: value2 } - brace_pattern = re.search(r'\{([^}]+)\}', text) + brace_pattern = re.search(r"\{([^}]+)\}", text) if brace_pattern: content = brace_pattern.group(1) pairs = extract_key_value_pairs(content) @@ -264,7 +268,6 @@ def _manual_json_extraction(text: str) -> Optional[Dict[str, Any]]: def _manual_array_extraction(text: str) -> Optional[List[Any]]: """Manually extract array-like data from text.""" - results = [] # Extract quoted strings quoted_strings = re.findall(r'"([^"]*)"', text) @@ -272,17 +275,17 @@ def _manual_array_extraction(text: str) -> Optional[List[Any]]: return [s.strip() for s in quoted_strings if s.strip()] # Extract numbered items - numbered_items = re.findall(r'\d+\.\s*(.+)', text) + numbered_items = re.findall(r"\d+\.\s*(.+)", text) if numbered_items: return [item.strip() for item in numbered_items if item.strip()] # Extract dash-separated items - dash_items = re.findall(r'-\s*(.+)', text) + dash_items = re.findall(r"-\s*(.+)", text) if dash_items: return [item.strip() for item in dash_items if item.strip()] # Extract comma-separated items - comma_items = re.findall(r'([^,]+)', text) + comma_items = re.findall(r"([^,]+)", text) if comma_items: cleaned_items = [item.strip() for item in comma_items if item.strip()] if len(cleaned_items) > 1: @@ -294,8 +297,8 @@ def _manual_array_extraction(text: str) -> Optional[List[Any]]: def _extract_clean_string(text: str) -> Optional[str]: """Extract a clean string from text.""" # Remove common artifacts - text = re.sub(r'```.*?```', '', text, flags=re.DOTALL) - text = re.sub(r'`.*?`', '', text) + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + text = re.sub(r"`.*?`", "", text) # Extract content between quotes quoted = re.findall(r'"([^"]*)"', text) @@ -315,13 +318,13 @@ def _clean_value(value: str) -> Any: value = value.strip() # Try to convert to appropriate type - if value.lower() in ['true', 'false']: - return value.lower() == 'true' - elif value.lower() == 'null': + if value.lower() in ["true", "false"]: + return value.lower() == "true" + elif value.lower() == "null": return None elif value.isdigit(): return int(value) - elif re.match(r'^\d+\.\d+$', value): + elif re.match(r"^\d+\.\d+$", value): return float(value) elif value.startswith('"') and value.endswith('"'): return value[1:-1] @@ -329,7 +332,9 @@ def _clean_value(value: str) -> Any: return value -def validate_json_structure(data: Any, required_keys: Optional[List[str]] = None) -> bool: +def validate_json_structure( + data: Any, required_keys: Optional[List[str]] = None +) -> bool: """ Validate that extracted data has the expected structure. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..0986832 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,28 @@ +site_name: intent-kit +site_description: Open-source Python framework for intent classification and execution +site_url: https://docs.intentkit.io +repo_url: https://github.com/Stephen-Collins-tech/intent-kit + +theme: + name: material + +plugins: + - search + - mkdocstrings + - literate-nav + +markdown_extensions: + - admonition + - codehilite + - toc: + permalink: true + +nav: + - Home: index.md + - Quickstart: quickstart.md + - API Reference: + - intent_kit: api_reference.md + - Examples: + - Calculator Bot: examples/calculator-bot.md + - Context-Aware Chatbot: examples/context-aware-chatbot.md + - Multi-Intent Routing: examples/multi-intent-routing.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bb863ed..2b2c28f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "intent-kit" +name = "intent_kit" version = "0.1.0" description = "An open-source Python library for building intent classification and execution systems that work with any AI backend." authors = [ @@ -51,6 +51,20 @@ dev = [ "python-dotenv>=1.0.0", "tqdm", "pyyaml", + "black>=24.4.2", + "black[jupyter]>=24.4.2", + "ruff>=0.4.7", + "mypy>=1.10.0", + "pre-commit>=3.6.0", + "build>=1.2.1", + "twine>=5.1.0", + "mkdocs>=1.5.0", + "mkdocs-material>=9.5.17", + "mkdocstrings[python]>=0.24.0", + "mkdocs-literate-nav>=0.6.0", + "ipykernel>=6.0.0", + "types-pyyaml>=6.0.12.20250516", + "types-networkx>=3.5.0.20250701", ] viz = [ "networkx>=3.5", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a6ab3f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Used for docs site generation +mkdocs>=1.5.0 +mkdocs-material>=9.5.17 +mkdocstrings[python]>=0.24.0 +mkdocs-literate-nav>=0.6.0>=0.6.0 +ipykernel>=6.0.0 \ No newline at end of file diff --git a/tests/intent_kit/graph/test_validation.py b/tests/intent_kit/graph/test_validation.py index 2b24900..cb4a189 100644 --- a/tests/intent_kit/graph/test_validation.py +++ b/tests/intent_kit/graph/test_validation.py @@ -6,7 +6,7 @@ from intent_kit.builder import handler, rule_splitter_node from intent_kit.classifiers import ClassifierNode from intent_kit.graph import IntentGraph -from intent_kit.graph.validation import GraphValidationError, validate_splitter_routing +from intent_kit.graph.validation import GraphValidationError def test_valid_graph(): @@ -18,7 +18,7 @@ def test_valid_graph(): name="greet", description="Greet the user", handler_func=lambda name: f"Hello {name}!", - param_schema={"name": str} + param_schema={"name": str}, ) # Create classifier node manually since we need a custom classifier @@ -26,7 +26,7 @@ def test_valid_graph(): name="main_classifier", classifier=lambda text, children, context: children[0], children=[greet_node], - description="Main classifier" + description="Main classifier", ) # Set parent reference @@ -36,7 +36,7 @@ def test_valid_graph(): splitter_node = rule_splitter_node( name="main_splitter", children=[classifier_node], # Routes to classifier - VALID - description="Split multi-intent inputs" + description="Split multi-intent inputs", ) # Create graph and validate @@ -55,14 +55,14 @@ def test_invalid_graph(): name="greet", description="Greet the user", handler_func=lambda name: f"Hello {name}!", - param_schema={"name": str} + param_schema={"name": str}, ) # Create splitter node that routes directly to intent nodes (INVALID) splitter_node = rule_splitter_node( name="invalid_splitter", children=[greet_node], # Routes directly to intent - INVALID - description="Invalid splitter" + description="Invalid splitter", ) # Create graph and try to validate diff --git a/tests/intent_kit/splitters/test_llm_splitter.py b/tests/intent_kit/splitters/test_llm_splitter.py index bbbcbf9..192f23d 100644 --- a/tests/intent_kit/splitters/test_llm_splitter.py +++ b/tests/intent_kit/splitters/test_llm_splitter.py @@ -1,10 +1,14 @@ """ Specific tests for llm_splitter function. """ + import unittest -from unittest.mock import Mock, patch -from intent_kit.splitters.llm_splitter import llm_splitter, _create_splitting_prompt, _parse_llm_response -import pytest +from unittest.mock import Mock +from intent_kit.splitters.llm_splitter import ( + llm_splitter, + _create_splitting_prompt, + _parse_llm_response, +) class TestLLMSplitterFunction(unittest.TestCase): @@ -20,8 +24,7 @@ def test_llm_splitting_success_valid_json(self): '["cancel my flight", "update my email"]' ) result = llm_splitter( - "Cancel my flight and update my email", - llm_client=self.mock_llm_client + "Cancel my flight and update my email", llm_client=self.mock_llm_client ) self.assertEqual(len(result), 2) self.assertEqual(result[0], "cancel my flight") @@ -29,31 +32,25 @@ def test_llm_splitting_success_valid_json(self): def test_llm_splitting_success_single_intent(self): """Test successful LLM-based splitting with single intent.""" - self.mock_llm_client.generate.return_value = ( - '["I need travel help"]' - ) - result = llm_splitter( - "I need travel help", - llm_client=self.mock_llm_client - ) + self.mock_llm_client.generate.return_value = '["I need travel help"]' + result = llm_splitter("I need travel help", llm_client=self.mock_llm_client) self.assertEqual(len(result), 1) self.assertEqual(result[0], "I need travel help") def test_llm_splitting_fallback_no_client(self): """Test fallback to rule-based when no LLM client provided.""" # Should fallback to rule_splitter - result = llm_splitter( - "travel help and account support", llm_client=None) + result = llm_splitter("travel help and account support", llm_client=None) self.assertEqual(len(result), 2) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") def test_llm_splitting_fallback_exception(self): """Test fallback to rule-based when LLM raises exception.""" - self.mock_llm_client.generate.side_effect = Exception( - "LLM service unavailable") - result = llm_splitter("travel help and account support", - llm_client=self.mock_llm_client) + self.mock_llm_client.generate.side_effect = Exception("LLM service unavailable") + result = llm_splitter( + "travel help and account support", llm_client=self.mock_llm_client + ) self.assertEqual(len(result), 2) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") @@ -61,8 +58,9 @@ def test_llm_splitting_fallback_exception(self): def test_llm_splitting_fallback_invalid_json(self): """Test fallback to rule-based when LLM returns invalid JSON.""" self.mock_llm_client.generate.return_value = "invalid json response" - result = llm_splitter("travel help and account support", - llm_client=self.mock_llm_client) + result = llm_splitter( + "travel help and account support", llm_client=self.mock_llm_client + ) self.assertEqual(len(result), 2) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") @@ -70,26 +68,29 @@ def test_llm_splitting_fallback_invalid_json(self): def test_llm_splitting_fallback_empty_response(self): """Test fallback to rule-based when LLM returns empty response.""" self.mock_llm_client.generate.return_value = "" - result = llm_splitter("travel help and account support", - llm_client=self.mock_llm_client) + result = llm_splitter( + "travel help and account support", llm_client=self.mock_llm_client + ) self.assertEqual(len(result), 2) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") def test_llm_splitting_fallback_no_results(self): """Test fallback to rule-based when LLM parsing returns no results.""" - self.mock_llm_client.generate.return_value = '[]' - result = llm_splitter("travel help and account support", - llm_client=self.mock_llm_client) + self.mock_llm_client.generate.return_value = "[]" + result = llm_splitter( + "travel help and account support", llm_client=self.mock_llm_client + ) self.assertEqual(len(result), 2) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") def test_llm_splitting_manual_parsing_fallback(self): """Test manual parsing fallback when JSON parsing fails.""" - self.mock_llm_client.generate.return_value = 'chunk1, chunk2' - result = llm_splitter("travel help and account support", - llm_client=self.mock_llm_client) + self.mock_llm_client.generate.return_value = "chunk1, chunk2" + result = llm_splitter( + "travel help and account support", llm_client=self.mock_llm_client + ) # Should now extract quoted/comma-separated items self.assertEqual(result, ["chunk1", "chunk2"]) @@ -102,9 +103,7 @@ def test_prompt_creation(self): def test_debug_logging(self): """Test debug logging functionality.""" - self.mock_llm_client.generate.return_value = ( - '["travel help"]' - ) + self.mock_llm_client.generate.return_value = '["travel help"]' # Should not raise, just exercise debug path result = llm_splitter( "travel help", debug=True, llm_client=self.mock_llm_client @@ -114,9 +113,7 @@ def test_debug_logging(self): def test_llm_client_called_with_prompt(self): """Test that LLM client is called with the generated prompt.""" - self.mock_llm_client.generate.return_value = ( - '["travel help"]' - ) + self.mock_llm_client.generate.return_value = '["travel help"]' llm_splitter("travel help", llm_client=self.mock_llm_client) self.mock_llm_client.generate.assert_called_once() call_args = self.mock_llm_client.generate.call_args[0][0] @@ -138,7 +135,7 @@ def test_parse_llm_response_invalid_json(self): def test_parse_llm_response_malformed_json(self): """Test parsing of malformed JSON response.""" - response = '[123]' # Not strings + response = "[123]" # Not strings result = _parse_llm_response(response) self.assertEqual(len(result), 0) @@ -159,7 +156,7 @@ def test_parse_llm_response_quoted_strings(self): def test_parse_llm_response_numbered_items(self): """Test manual parsing with numbered items.""" - response = '1. cancel flight\n2. update email' + response = "1. cancel flight\n2. update email" result = _parse_llm_response(response) self.assertEqual(len(result), 2) self.assertEqual(result[0], "cancel flight") @@ -167,7 +164,7 @@ def test_parse_llm_response_numbered_items(self): def test_parse_llm_response_dash_items(self): """Test manual parsing with dash-separated items.""" - response = '- cancel flight\n- update email' + response = "- cancel flight\n- update email" result = _parse_llm_response(response) self.assertEqual(len(result), 2) self.assertEqual(result[0], "cancel flight") @@ -181,7 +178,7 @@ def test_parse_llm_response_valid_json(): def test_parse_llm_response_malformed_json(): - response = '[123]' + response = "[123]" result = _parse_llm_response(response) assert result == [] @@ -193,28 +190,28 @@ def test_parse_llm_response_quoted_strings(): def test_parse_llm_response_numbered_items(): - response = '1. cancel flight\n2. update email' + response = "1. cancel flight\n2. update email" result = _parse_llm_response(response) assert result == ["cancel flight", "update email"] def test_parse_llm_response_dash_items(): - response = '- cancel flight\n- update email' + response = "- cancel flight\n- update email" result = _parse_llm_response(response) assert result == ["cancel flight", "update email"] def test_parse_llm_response_empty(): - response = '' + response = "" result = _parse_llm_response(response) assert result == [] def test_parse_llm_response_garbage(): - response = 'nonsense text with no structure' + response = "nonsense text with no structure" result = _parse_llm_response(response) assert result == [] -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/intent_kit/splitters/test_rule_splitter.py b/tests/intent_kit/splitters/test_rule_splitter.py index e5f0895..e0ce233 100644 --- a/tests/intent_kit/splitters/test_rule_splitter.py +++ b/tests/intent_kit/splitters/test_rule_splitter.py @@ -1,8 +1,8 @@ """ Specific tests for rule_splitter function. """ + import unittest -from unittest.mock import Mock from intent_kit.splitters.rule_splitter import rule_splitter @@ -67,8 +67,7 @@ def test_case_insensitive_splitting(self): def test_multiple_conjunctions(self): """Test input with multiple conjunctions.""" - result = rule_splitter( - "travel help, account support and booking flights") + result = rule_splitter("travel help, account support and booking flights") self.assertEqual(len(result), 3) self.assertEqual(result[0], "travel help") self.assertEqual(result[1], "account support") @@ -76,11 +75,9 @@ def test_multiple_conjunctions(self): def test_no_match_found(self): """Test when no conjunctions are found.""" - result = rule_splitter( - "I need help with something completely unrelated") + result = rule_splitter("I need help with something completely unrelated") self.assertEqual(len(result), 1) - self.assertEqual( - result[0], "I need help with something completely unrelated") + self.assertEqual(result[0], "I need help with something completely unrelated") def test_empty_input(self): """Test handling of empty input.""" @@ -100,5 +97,5 @@ def test_debug_logging(self): self.assertEqual(result[1], "account support") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_context.py b/tests/test_context.py index 505a272..9c09c8b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,9 +3,12 @@ """ import pytest -from datetime import datetime -from intent_kit.context import IntentContext, ContextField, ContextHistoryEntry -from intent_kit.context.dependencies import ContextDependencies, declare_dependencies, validate_context_dependencies, merge_dependencies +from intent_kit.context import IntentContext +from intent_kit.context.dependencies import ( + declare_dependencies, + validate_context_dependencies, + merge_dependencies, +) class TestIntentContext: @@ -140,8 +143,11 @@ def test_context_thread_safety(self): def worker(thread_id): for i in range(10): - context.set(f"thread_{thread_id}_key_{i}", - f"value_{i}", modified_by=f"thread_{thread_id}") + context.set( + f"thread_{thread_id}_key_{i}", + f"value_{i}", + modified_by=f"thread_{thread_id}", + ) # Small delay to increase chance of race conditions time.sleep(0.001) value = context.get(f"thread_{thread_id}_key_{i}") @@ -174,7 +180,7 @@ def test_declare_dependencies(self): deps = declare_dependencies( inputs={"input1", "input2"}, outputs={"output1"}, - description="Test dependencies" + description="Test dependencies", ) assert deps.inputs == {"input1", "input2"} @@ -188,8 +194,7 @@ def test_validate_context_dependencies(self): context.set("input2", "value2") deps = declare_dependencies( - inputs={"input1", "input2", "missing_input"}, - outputs={"output1"} + inputs={"input1", "input2", "missing_input"}, outputs={"output1"} ) result = validate_context_dependencies(deps, context, strict=False) @@ -204,8 +209,7 @@ def test_validate_context_dependencies_strict(self): context.set("input1", "value1") deps = declare_dependencies( - inputs={"input1", "missing_input"}, - outputs={"output1"} + inputs={"input1", "missing_input"}, outputs={"output1"} ) result = validate_context_dependencies(deps, context, strict=True) diff --git a/tests/test_eval_api.py b/tests/test_eval_api.py index 0aad71c..3f34877 100644 --- a/tests/test_eval_api.py +++ b/tests/test_eval_api.py @@ -11,18 +11,16 @@ load_dataset, run_eval, run_eval_from_path, - run_eval_from_module, EvalTestCase, Dataset, EvalTestResult, - EvalResult + EvalResult, ) def test_load_dataset(): """Test loading a dataset from YAML.""" - dataset = load_dataset( - "intent_kit/evals/datasets/classifier_node_llm.yaml") + dataset = load_dataset("intent_kit/evals/datasets/classifier_node_llm.yaml") assert dataset.name == "classifier_node_llm" assert dataset.node_type == "classifier" @@ -48,7 +46,7 @@ def test_load_dataset_malformed(): import tempfile import yaml - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump({"invalid": "data"}, f) temp_path = f.name @@ -61,11 +59,7 @@ def test_load_dataset_malformed(): def test_test_case_defaults(): """Test EvalTestCase with default context.""" - test_case = EvalTestCase( - input="test input", - expected="test expected", - context={} - ) + test_case = EvalTestCase(input="test input", expected="test expected", context={}) assert test_case.input == "test input" assert test_case.expected == "test expected" @@ -80,7 +74,7 @@ def test_dataset_defaults(): description="", node_type="test", node_name="test_node", - test_cases=test_cases + test_cases=test_cases, ) assert dataset.description == "" @@ -91,12 +85,12 @@ def test_eval_result_methods(): results = [ EvalTestResult("input1", "expected1", "actual1", True, {}), EvalTestResult("input2", "expected2", "actual2", False, {}), - EvalTestResult("input3", "expected3", "actual3", True, {}) + EvalTestResult("input3", "expected3", "actual3", True, {}), ] eval_result = EvalResult(results, "test_dataset") - assert eval_result.accuracy() == 2/3 + assert eval_result.accuracy() == 2 / 3 assert eval_result.passed_count() == 2 assert eval_result.failed_count() == 1 assert eval_result.total_count() == 3 @@ -118,12 +112,13 @@ def test_eval_result_empty(): def test_run_eval_with_callable(): """Test run_eval with a callable node.""" + def simple_node(input_text, context=None): return f"Processed: {input_text}" test_cases = [ EvalTestCase("hello", "Processed: hello", {}), - EvalTestCase("world", "Processed: world", {}) + EvalTestCase("world", "Processed: world", {}), ] dataset = Dataset( @@ -131,7 +126,7 @@ def simple_node(input_text, context=None): description="Test dataset", node_type="test", node_name="simple_node", - test_cases=test_cases + test_cases=test_cases, ) result = run_eval(dataset, simple_node) @@ -143,6 +138,7 @@ def simple_node(input_text, context=None): def test_run_eval_with_error(): """Test run_eval with a node that raises exceptions.""" + def error_node(input_text, context=None): if "error" in input_text.lower(): raise ValueError("Intentional error") @@ -152,7 +148,7 @@ def error_node(input_text, context=None): EvalTestCase("hello", "success", {}), # This will fail due to exception EvalTestCase("error", "success", {}), - EvalTestCase("world", "success", {}) + EvalTestCase("world", "success", {}), ] dataset = Dataset( @@ -160,12 +156,12 @@ def error_node(input_text, context=None): description="Test dataset", node_type="test", node_name="error_node", - test_cases=test_cases + test_cases=test_cases, ) result = run_eval(dataset, error_node) - assert result.accuracy() == 2/3 + assert result.accuracy() == 2 / 3 assert not result.all_passed() assert result.failed_count() == 1 assert result.errors()[0].error == "Intentional error" @@ -173,6 +169,7 @@ def error_node(input_text, context=None): def test_run_eval_fail_fast(): """Test run_eval with fail_fast=True.""" + def error_node(input_text, context=None): if "error" in input_text.lower(): raise ValueError("Intentional error") @@ -183,7 +180,7 @@ def error_node(input_text, context=None): # This will fail and stop execution EvalTestCase("error", "success", {}), # This won't run due to fail_fast - EvalTestCase("world", "success", {}) + EvalTestCase("world", "success", {}), ] dataset = Dataset( @@ -191,7 +188,7 @@ def error_node(input_text, context=None): description="Test dataset", node_type="test", node_name="error_node", - test_cases=test_cases + test_cases=test_cases, ) result = run_eval(dataset, error_node, fail_fast=True) @@ -203,6 +200,7 @@ def error_node(input_text, context=None): def test_run_eval_custom_comparator(): """Test run_eval with custom comparator.""" + def simple_node(input_text, context=None): return input_text.upper() @@ -211,7 +209,7 @@ def case_insensitive_comparator(expected, actual): test_cases = [ EvalTestCase("hello", "HELLO", {}), - EvalTestCase("world", "WORLD", {}) + EvalTestCase("world", "WORLD", {}), ] dataset = Dataset( @@ -219,11 +217,10 @@ def case_insensitive_comparator(expected, actual): description="Test dataset", node_type="test", node_name="simple_node", - test_cases=test_cases + test_cases=test_cases, ) - result = run_eval(dataset, simple_node, - comparator=case_insensitive_comparator) + result = run_eval(dataset, simple_node, comparator=case_insensitive_comparator) assert result.accuracy() == 1.0 assert result.all_passed() @@ -231,6 +228,7 @@ def case_insensitive_comparator(expected, actual): def test_run_eval_from_path(): """Test run_eval_from_path convenience function.""" + def simple_node(input_text, context=None): return f"Processed: {input_text}" @@ -243,18 +241,18 @@ def simple_node(input_text, context=None): "name": "test_dataset", "description": "Test dataset", "node_type": "test", - "node_name": "simple_node" + "node_name": "simple_node", }, "test_cases": [ { "input": "hello", "expected": "Processed: hello", - "context": {"user_id": "test"} + "context": {"user_id": "test"}, } - ] + ], } - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(test_data, f) temp_path = f.name @@ -270,15 +268,15 @@ def test_save_results(): """Test saving results to different formats.""" results = [ EvalTestResult("input1", "expected1", "actual1", True, {}), - EvalTestResult("input2", "expected2", "actual2", - False, {}, "test error") + EvalTestResult("input2", "expected2", "actual2", False, {}, "test error"), ] eval_result = EvalResult(results, "test_dataset") # Test CSV save import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: csv_path = f.name try: @@ -288,7 +286,7 @@ def test_save_results(): Path(csv_path).unlink() # Test JSON save - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json_path = f.name try: @@ -298,7 +296,7 @@ def test_save_results(): Path(json_path).unlink() # Test Markdown save - with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: md_path = f.name try: diff --git a/tests/test_ollama_client.py b/tests/test_ollama_client.py index bcf3b8b..e73a4dc 100644 --- a/tests/test_ollama_client.py +++ b/tests/test_ollama_client.py @@ -20,7 +20,7 @@ def test_init_custom_base_url(self): client = OllamaClient(base_url="http://custom:11434") assert client.base_url == "http://custom:11434" - @patch('ollama.Client') + @patch("ollama.Client") def test_get_client_success(self, mock_client_class): """Test successful client creation.""" mock_client = Mock() @@ -28,10 +28,9 @@ def test_get_client_success(self, mock_client_class): client = OllamaClient() assert client._client == mock_client - mock_client_class.assert_called_once_with( - host="http://localhost:11434") + mock_client_class.assert_called_once_with(host="http://localhost:11434") - @patch('ollama.Client') + @patch("ollama.Client") def test_get_client_import_error(self, mock_client_class): """Test client creation with import error.""" mock_client_class.side_effect = ImportError("No module named 'ollama'") @@ -39,12 +38,12 @@ def test_get_client_import_error(self, mock_client_class): with pytest.raises(ImportError, match="Ollama package not installed"): OllamaClient() - @patch('ollama.Client') + @patch("ollama.Client") def test_generate_success(self, mock_client_class): """Test successful text generation.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'response': 'Test response'} + mock_response = {"response": "Test response"} mock_client.generate.return_value = mock_response client = OllamaClient() @@ -52,38 +51,31 @@ def test_generate_success(self, mock_client_class): assert result == "Test response" mock_client.generate.assert_called_once_with( - model="llama2", - prompt="Test prompt" + model="llama2", prompt="Test prompt" ) - @patch('ollama.Client') + @patch("ollama.Client") def test_generate_stream_success(self, mock_client_class): """Test successful streaming generation.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_chunks = [ - {'response': 'Hello'}, - {'response': ' '}, - {'response': 'World'} - ] + mock_chunks = [{"response": "Hello"}, {"response": " "}, {"response": "World"}] mock_client.generate.return_value = mock_chunks client = OllamaClient() result = list(client.generate_stream("Test prompt", model="llama2")) - assert result == ['Hello', ' ', 'World'] + assert result == ["Hello", " ", "World"] mock_client.generate.assert_called_once_with( - model="llama2", - prompt="Test prompt", - stream=True + model="llama2", prompt="Test prompt", stream=True ) - @patch('ollama.Client') + @patch("ollama.Client") def test_chat_success(self, mock_client_class): """Test successful chat functionality.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'message': {'content': 'Hello there!'}} + mock_response = {"message": {"content": "Hello there!"}} mock_client.chat.return_value = mock_response client = OllamaClient() @@ -91,20 +83,17 @@ def test_chat_success(self, mock_client_class): result = client.chat(messages, model="llama2") assert result == "Hello there!" - mock_client.chat.assert_called_once_with( - model="llama2", - messages=messages - ) + mock_client.chat.assert_called_once_with(model="llama2", messages=messages) - @patch('ollama.Client') + @patch("ollama.Client") def test_chat_stream_success(self, mock_client_class): """Test successful streaming chat functionality.""" mock_client = Mock() mock_client_class.return_value = mock_client mock_chunks = [ - {'message': {'content': 'Hello'}}, - {'message': {'content': ' '}}, - {'message': {'content': 'World'}} + {"message": {"content": "Hello"}}, + {"message": {"content": " "}}, + {"message": {"content": "World"}}, ] mock_client.chat.return_value = mock_chunks @@ -112,14 +101,12 @@ def test_chat_stream_success(self, mock_client_class): messages = [{"role": "user", "content": "Hello"}] result = list(client.chat_stream(messages, model="llama2")) - assert result == ['Hello', ' ', 'World'] + assert result == ["Hello", " ", "World"] mock_client.chat.assert_called_once_with( - model="llama2", - messages=messages, - stream=True + model="llama2", messages=messages, stream=True ) - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_success(self, mock_client_class): """Test successful model listing with new response structure.""" mock_client = Mock() @@ -127,11 +114,11 @@ def test_list_models_success(self, mock_client_class): # Create mock models with .model attribute mock_model1 = Mock() - mock_model1.model = 'llama2' + mock_model1.model = "llama2" mock_model2 = Mock() - mock_model2.model = 'mistral' + mock_model2.model = "mistral" mock_model3 = Mock() - mock_model3.model = 'codellama' + mock_model3.model = "codellama" # Create mock response with .models attribute mock_response = Mock() @@ -141,18 +128,18 @@ def test_list_models_success(self, mock_client_class): client = OllamaClient() result = client.list_models() - assert result == ['llama2', 'mistral', 'codellama'] + assert result == ["llama2", "mistral", "codellama"] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_dict_fallback(self, mock_client_class): """Test model listing with dictionary fallback.""" mock_client = Mock() mock_client_class.return_value = mock_client # Use a mock object with .models attribute containing dicts - mock_model1 = {'model': 'llama2'} - mock_model2 = {'model': 'mistral'} - mock_model3 = {'model': 'codellama'} + mock_model1 = {"model": "llama2"} + mock_model2 = {"model": "mistral"} + mock_model3 = {"model": "codellama"} mock_response = Mock() mock_response.models = [mock_model1, mock_model2, mock_model3] mock_client.list.return_value = mock_response @@ -160,26 +147,26 @@ def test_list_models_dict_fallback(self, mock_client_class): client = OllamaClient() result = client.list_models() - assert result == ['llama2', 'mistral', 'codellama'] + assert result == ["llama2", "mistral", "codellama"] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_string_fallback(self, mock_client_class): """Test model listing with string fallback.""" mock_client = Mock() mock_client_class.return_value = mock_client # Use a mock object with .models attribute containing strings mock_response = Mock() - mock_response.models = ['llama2', 'mistral', 'codellama'] + mock_response.models = ["llama2", "mistral", "codellama"] mock_client.list.return_value = mock_response client = OllamaClient() result = client.list_models() - assert result == ['llama2', 'mistral', 'codellama'] + assert result == ["llama2", "mistral", "codellama"] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_empty_response(self, mock_client_class): """Test model listing with empty response.""" mock_client = Mock() @@ -196,14 +183,14 @@ def test_list_models_empty_response(self, mock_client_class): assert result == [] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_unexpected_structure(self, mock_client_class): """Test model listing with unexpected response structure.""" mock_client = Mock() mock_client_class.return_value = mock_client # Test with unexpected structure - mock_response = {'unexpected': 'structure'} + mock_response = {"unexpected": "structure"} mock_client.list.return_value = mock_response client = OllamaClient() @@ -212,12 +199,12 @@ def test_list_models_unexpected_structure(self, mock_client_class): assert result == [] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_show_model_success(self, mock_client_class): """Test successful model info retrieval.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'name': 'llama2', 'size': '3.8GB'} + mock_response = {"name": "llama2", "size": "3.8GB"} mock_client.show.return_value = mock_response client = OllamaClient() @@ -226,12 +213,12 @@ def test_show_model_success(self, mock_client_class): assert result == mock_response mock_client.show.assert_called_once_with("llama2") - @patch('ollama.Client') + @patch("ollama.Client") def test_pull_model_success(self, mock_client_class): """Test successful model pulling.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'status': 'success'} + mock_response = {"status": "success"} mock_client.pull.return_value = mock_response client = OllamaClient() @@ -240,12 +227,12 @@ def test_pull_model_success(self, mock_client_class): assert result == mock_response mock_client.pull.assert_called_once_with("llama2") - @patch('ollama.Client') + @patch("ollama.Client") def test_generate_text_alias(self, mock_client_class): """Test generate_text alias method.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'response': 'Test response'} + mock_response = {"response": "Test response"} mock_client.generate.return_value = mock_response client = OllamaClient() @@ -253,13 +240,12 @@ def test_generate_text_alias(self, mock_client_class): assert result == "Test response" mock_client.generate.assert_called_once_with( - model="llama2", - prompt="Test prompt" + model="llama2", prompt="Test prompt" ) def test_is_available_with_ollama(self): """Test is_available when ollama is installed.""" - with patch('ollama.Client'): + with patch("ollama.Client"): assert OllamaClient.is_available() is True def test_is_available_without_ollama(self): @@ -268,12 +254,12 @@ def test_is_available_without_ollama(self): # So we skip it or just assert True for now. assert True - @patch('ollama.Client') + @patch("ollama.Client") def test_generate_empty_response(self, mock_client_class): """Test handling of empty response.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'response': ''} + mock_response = {"response": ""} mock_client.generate.return_value = mock_response client = OllamaClient() @@ -281,12 +267,12 @@ def test_generate_empty_response(self, mock_client_class): assert result == "" - @patch('ollama.Client') + @patch("ollama.Client") def test_generate_none_response(self, mock_client_class): """Test handling of None response.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'response': None} + mock_response = {"response": None} mock_client.generate.return_value = mock_response client = OllamaClient() @@ -294,12 +280,12 @@ def test_generate_none_response(self, mock_client_class): assert result == "" - @patch('ollama.Client') + @patch("ollama.Client") def test_chat_empty_response(self, mock_client_class): """Test handling of empty chat response.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'message': {'content': ''}} + mock_response = {"message": {"content": ""}} mock_client.chat.return_value = mock_response client = OllamaClient() @@ -308,12 +294,12 @@ def test_chat_empty_response(self, mock_client_class): assert result == "" - @patch('ollama.Client') + @patch("ollama.Client") def test_chat_none_response(self, mock_client_class): """Test handling of None chat response.""" mock_client = Mock() mock_client_class.return_value = mock_client - mock_response = {'message': {'content': None}} + mock_response = {"message": {"content": None}} mock_client.chat.return_value = mock_response client = OllamaClient() @@ -322,7 +308,7 @@ def test_chat_none_response(self, mock_client_class): assert result == "" - @patch('ollama.Client') + @patch("ollama.Client") def test_list_models_exception_handling(self, mock_client_class): """Test exception handling in list_models.""" mock_client = Mock() @@ -335,7 +321,7 @@ def test_list_models_exception_handling(self, mock_client_class): assert result == [] mock_client.list.assert_called_once() - @patch('ollama.Client') + @patch("ollama.Client") def test_show_model_exception_handling(self, mock_client_class): """Test exception handling in show_model.""" mock_client = Mock() @@ -346,7 +332,7 @@ def test_show_model_exception_handling(self, mock_client_class): with pytest.raises(Exception, match="Model not found"): client.show_model("nonexistent") - @patch('ollama.Client') + @patch("ollama.Client") def test_pull_model_exception_handling(self, mock_client_class): """Test exception handling in pull_model.""" mock_client = Mock() diff --git a/tests/test_remediation.py b/tests/test_remediation.py index edf3ad1..bf4256c 100644 --- a/tests/test_remediation.py +++ b/tests/test_remediation.py @@ -2,7 +2,6 @@ Tests for the remediation strategies. """ -import pytest import json from unittest.mock import Mock, patch, MagicMock from intent_kit.handlers.remediation import ( @@ -20,10 +19,9 @@ create_fallback_strategy, create_self_reflect_strategy, create_consensus_vote_strategy, - create_alternate_prompt_strategy + create_alternate_prompt_strategy, ) -from intent_kit.node.types import ExecutionResult, ExecutionError -from intent_kit.node.enums import NodeType +from intent_kit.node.types import ExecutionError from intent_kit.context import IntentContext from intent_kit.utils.text_utils import extract_json_from_text @@ -48,7 +46,7 @@ def test_retry_strategy_success_on_first_attempt(self): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -67,7 +65,7 @@ def test_retry_strategy_success_on_retry(self): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -85,7 +83,7 @@ def test_retry_strategy_all_attempts_fail(self): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is None @@ -103,13 +101,12 @@ def test_retry_strategy_with_context(self): user_input="test input", context=context, handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None assert result.success is True - handler_func.assert_called_once_with( - **validated_params, context=context) + handler_func.assert_called_once_with(**validated_params, context=context) def test_retry_strategy_missing_parameters(self): """Test retry strategy with missing handler_func or validated_params.""" @@ -117,18 +114,14 @@ def test_retry_strategy_missing_parameters(self): # Missing handler_func result = strategy.execute( - node_name="test_node", - user_input="test input", - validated_params={"x": 5} + node_name="test_node", user_input="test input", validated_params={"x": 5} ) assert result is None # Missing validated_params handler_func = Mock() result = strategy.execute( - node_name="test_node", - user_input="test input", - handler_func=handler_func + node_name="test_node", user_input="test input", handler_func=handler_func ) assert result is None @@ -139,8 +132,7 @@ class TestFallbackToAnotherNodeStrategy: def test_fallback_strategy_creation(self): """Test creating a fallback strategy.""" fallback_handler = Mock() - strategy = FallbackToAnotherNodeStrategy( - fallback_handler, "fallback_name") + strategy = FallbackToAnotherNodeStrategy(fallback_handler, "fallback_name") assert strategy.name == "fallback_to_another_node" assert strategy.fallback_handler == fallback_handler assert strategy.fallback_name == "fallback_name" @@ -155,7 +147,7 @@ def test_fallback_strategy_success(self): node_name="test_node", user_input="test input", handler_func=Mock(), - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -176,13 +168,12 @@ def test_fallback_strategy_with_context(self): user_input="test input", context=context, handler_func=Mock(), - validated_params=validated_params + validated_params=validated_params, ) assert result is not None assert result.success is True - fallback_handler.assert_called_once_with( - **validated_params, context=context) + fallback_handler.assert_called_once_with(**validated_params, context=context) def test_fallback_strategy_no_validated_params(self): """Test fallback strategy when no validated_params provided.""" @@ -190,9 +181,7 @@ def test_fallback_strategy_no_validated_params(self): strategy = FallbackToAnotherNodeStrategy(fallback_handler, "fallback") result = strategy.execute( - node_name="test_node", - user_input="test input", - handler_func=Mock() + node_name="test_node", user_input="test input", handler_func=Mock() ) assert result is not None @@ -208,7 +197,7 @@ def test_fallback_strategy_failure(self): node_name="test_node", user_input="test input", handler_func=Mock(), - validated_params={"x": 5} + validated_params={"x": 5}, ) assert result is None @@ -217,31 +206,31 @@ def test_fallback_strategy_failure(self): class TestSelfReflectStrategy: """Test the SelfReflectStrategy.""" - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_self_reflect_strategy_creation(self, mock_llm_factory): """Test creating a self-reflect strategy.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = SelfReflectStrategy(llm_config, max_reflections=2) assert strategy.name == "self_reflect" assert strategy.llm_config == llm_config assert strategy.max_reflections == 2 - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_self_reflect_strategy_success(self, mock_llm_factory): """Test self-reflect strategy when LLM provides good analysis.""" # Mock LLM client mock_client = Mock() - mock_client.generate.return_value = json.dumps({ - "analysis": "The handler failed because of negative input", - "suggestions": ["Use absolute value", "Use positive numbers"], - "modified_params": {"x": 5}, - "confidence": 0.8 - }) + mock_client.generate.return_value = json.dumps( + { + "analysis": "The handler failed because of negative input", + "suggestions": ["Use absolute value", "Use positive numbers"], + "modified_params": {"x": 5}, + "confidence": 0.8, + } + ) mock_llm_factory.create_client.return_value = mock_client - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = SelfReflectStrategy(llm_config, max_reflections=1) handler_func = Mock(return_value="success") validated_params = {"x": -3} @@ -255,8 +244,8 @@ def test_self_reflect_strategy_success(self, mock_llm_factory): error_type="ValueError", message="Cannot handle negative numbers", node_name="test_node", - node_path=["test_node"] - ) + node_path=["test_node"], + ), ) assert result is not None @@ -265,7 +254,7 @@ def test_self_reflect_strategy_success(self, mock_llm_factory): assert result.params == {"x": 5} # Modified params handler_func.assert_called_once_with(x=5) - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_self_reflect_strategy_invalid_json(self, mock_llm_factory): """Test self-reflect strategy when LLM returns invalid JSON.""" # Mock LLM client @@ -273,8 +262,7 @@ def test_self_reflect_strategy_invalid_json(self, mock_llm_factory): mock_client.generate.return_value = "invalid json" mock_llm_factory.create_client.return_value = mock_client - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = SelfReflectStrategy(llm_config, max_reflections=1) handler_func = Mock(return_value="success") validated_params = {"x": 3} @@ -283,7 +271,7 @@ def test_self_reflect_strategy_invalid_json(self, mock_llm_factory): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -292,7 +280,7 @@ def test_self_reflect_strategy_invalid_json(self, mock_llm_factory): # Should use original params when JSON is invalid assert result.params == validated_params - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_self_reflect_strategy_llm_failure(self, mock_llm_factory): """Test self-reflect strategy when LLM fails.""" # Mock LLM client that raises exception @@ -300,8 +288,7 @@ def test_self_reflect_strategy_llm_failure(self, mock_llm_factory): mock_client.generate.side_effect = Exception("LLM error") mock_llm_factory.create_client.return_value = mock_client - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = SelfReflectStrategy(llm_config, max_reflections=1) handler_func = Mock() validated_params = {"x": 3} @@ -310,7 +297,7 @@ def test_self_reflect_strategy_llm_failure(self, mock_llm_factory): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is None @@ -319,44 +306,47 @@ def test_self_reflect_strategy_llm_failure(self, mock_llm_factory): class TestConsensusVoteStrategy: """Test the ConsensusVoteStrategy.""" - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_consensus_vote_strategy_creation(self, mock_llm_factory): """Test creating a consensus vote strategy.""" llm_configs = [ {"provider": "openai", "model": "gpt-4", "api_key": "test-key"}, - {"provider": "google", "model": "gemini", "api_key": "test-key"} + {"provider": "google", "model": "gemini", "api_key": "test-key"}, ] strategy = ConsensusVoteStrategy(llm_configs, vote_threshold=0.6) assert strategy.name == "consensus_vote" assert strategy.llm_configs == llm_configs assert strategy.vote_threshold == 0.6 - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_consensus_vote_strategy_success(self, mock_llm_factory): """Test consensus vote strategy when models agree.""" # Mock LLM clients mock_client1 = Mock() - mock_client1.generate.return_value = json.dumps({ - "approach": "Use positive numbers", - "confidence": 0.8, - "modified_params": {"x": 5}, - "reasoning": "Negative numbers cause errors" - }) + mock_client1.generate.return_value = json.dumps( + { + "approach": "Use positive numbers", + "confidence": 0.8, + "modified_params": {"x": 5}, + "reasoning": "Negative numbers cause errors", + } + ) mock_client2 = Mock() - mock_client2.generate.return_value = json.dumps({ - "approach": "Use absolute value", - "confidence": 0.9, - "modified_params": {"x": 3}, - "reasoning": "Convert negative to positive" - }) + mock_client2.generate.return_value = json.dumps( + { + "approach": "Use absolute value", + "confidence": 0.9, + "modified_params": {"x": 3}, + "reasoning": "Convert negative to positive", + } + ) - mock_llm_factory.create_client.side_effect = [ - mock_client1, mock_client2] + mock_llm_factory.create_client.side_effect = [mock_client1, mock_client2] llm_configs = [ {"provider": "openai", "model": "gpt-4", "api_key": "test-key"}, - {"provider": "google", "model": "gemini", "api_key": "test-key"} + {"provider": "google", "model": "gemini", "api_key": "test-key"}, ] strategy = ConsensusVoteStrategy(llm_configs, vote_threshold=0.5) handler_func = Mock(return_value="success") @@ -371,8 +361,8 @@ def test_consensus_vote_strategy_success(self, mock_llm_factory): error_type="ValueError", message="Cannot handle negative numbers", node_name="test_node", - node_path=["test_node"] - ) + node_path=["test_node"], + ), ) assert result is not None @@ -381,35 +371,39 @@ def test_consensus_vote_strategy_success(self, mock_llm_factory): # Should use the highest confidence vote (model 2 with x=3) assert result.params == {"x": 3} - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_consensus_vote_strategy_low_confidence(self, mock_llm_factory): """Test consensus vote strategy when confidence is too low.""" # Mock LLM clients with low confidence mock_client1 = Mock() - mock_client1.generate.return_value = json.dumps({ - "approach": "Try something", - "confidence": 0.3, - "modified_params": {"x": 5}, - "reasoning": "Low confidence approach" - }) + mock_client1.generate.return_value = json.dumps( + { + "approach": "Try something", + "confidence": 0.3, + "modified_params": {"x": 5}, + "reasoning": "Low confidence approach", + } + ) mock_client2 = Mock() - mock_client2.generate.return_value = json.dumps({ - "approach": "Try another thing", - "confidence": 0.4, - "modified_params": {"x": 3}, - "reasoning": "Another low confidence approach" - }) + mock_client2.generate.return_value = json.dumps( + { + "approach": "Try another thing", + "confidence": 0.4, + "modified_params": {"x": 3}, + "reasoning": "Another low confidence approach", + } + ) - mock_llm_factory.create_client.side_effect = [ - mock_client1, mock_client2] + mock_llm_factory.create_client.side_effect = [mock_client1, mock_client2] llm_configs = [ {"provider": "openai", "model": "gpt-4", "api_key": "test-key"}, - {"provider": "google", "model": "gemini", "api_key": "test-key"} + {"provider": "google", "model": "gemini", "api_key": "test-key"}, ] strategy = ConsensusVoteStrategy( - llm_configs, vote_threshold=0.6) # Higher threshold + llm_configs, vote_threshold=0.6 + ) # Higher threshold handler_func = Mock() validated_params = {"x": -3} @@ -417,12 +411,12 @@ def test_consensus_vote_strategy_low_confidence(self, mock_llm_factory): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is None # Should fail due to low confidence - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_consensus_vote_strategy_no_votes(self, mock_llm_factory): """Test consensus vote strategy when no models provide valid votes.""" # Mock LLM client that fails @@ -430,8 +424,7 @@ def test_consensus_vote_strategy_no_votes(self, mock_llm_factory): mock_client.generate.side_effect = Exception("LLM error") mock_llm_factory.create_client.return_value = mock_client - llm_configs = [{"provider": "openai", - "model": "gpt-4", "api_key": "test-key"}] + llm_configs = [{"provider": "openai", "model": "gpt-4", "api_key": "test-key"}] strategy = ConsensusVoteStrategy(llm_configs, vote_threshold=0.6) handler_func = Mock() validated_params = {"x": -3} @@ -440,7 +433,7 @@ def test_consensus_vote_strategy_no_votes(self, mock_llm_factory): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is None @@ -451,8 +444,7 @@ class TestRetryWithAlternatePromptStrategy: def test_alternate_prompt_strategy_creation(self): """Test creating an alternate prompt strategy.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = RetryWithAlternatePromptStrategy(llm_config) assert strategy.name == "retry_with_alternate_prompt" assert strategy.llm_config == llm_config @@ -460,17 +452,17 @@ def test_alternate_prompt_strategy_creation(self): def test_alternate_prompt_strategy_custom_prompts(self): """Test alternate prompt strategy with custom prompts.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} custom_prompts = ["Try {user_input}", "Test {user_input}"] strategy = RetryWithAlternatePromptStrategy(llm_config, custom_prompts) assert strategy.alternate_prompts == custom_prompts - @patch('intent_kit.services.llm_factory.LLMFactory') - def test_alternate_prompt_strategy_success_with_absolute_values(self, mock_llm_factory): + @patch("intent_kit.services.llm_factory.LLMFactory") + def test_alternate_prompt_strategy_success_with_absolute_values( + self, mock_llm_factory + ): """Test alternate prompt strategy with absolute value modification.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = RetryWithAlternatePromptStrategy(llm_config) handler_func = Mock(return_value="success") validated_params = {"x": -3} @@ -479,7 +471,7 @@ def test_alternate_prompt_strategy_success_with_absolute_values(self, mock_llm_f node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -488,11 +480,12 @@ def test_alternate_prompt_strategy_success_with_absolute_values(self, mock_llm_f # Should use absolute value of -3, which is 3 assert result.params == {"x": 3} - @patch('intent_kit.services.llm_factory.LLMFactory') - def test_alternate_prompt_strategy_success_with_positive_values(self, mock_llm_factory): + @patch("intent_kit.services.llm_factory.LLMFactory") + def test_alternate_prompt_strategy_success_with_positive_values( + self, mock_llm_factory + ): """Test alternate prompt strategy with positive value modification.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = RetryWithAlternatePromptStrategy(llm_config) handler_func = Mock(side_effect=[Exception("fail"), "success"]) validated_params = {"x": -3} @@ -501,7 +494,7 @@ def test_alternate_prompt_strategy_success_with_positive_values(self, mock_llm_f node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -510,11 +503,10 @@ def test_alternate_prompt_strategy_success_with_positive_values(self, mock_llm_f # Should use max(0, -3) = 0 assert result.params == {"x": 0} - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_alternate_prompt_strategy_all_strategies_fail(self, mock_llm_factory): """Test alternate prompt strategy when all strategies fail.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = RetryWithAlternatePromptStrategy(llm_config) handler_func = Mock(side_effect=Exception("always fail")) validated_params = {"x": -3} @@ -523,16 +515,15 @@ def test_alternate_prompt_strategy_all_strategies_fail(self, mock_llm_factory): node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is None - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_alternate_prompt_strategy_mixed_parameter_types(self, mock_llm_factory): """Test alternate prompt strategy with mixed parameter types.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = RetryWithAlternatePromptStrategy(llm_config) handler_func = Mock(return_value="success") validated_params = {"x": -3, "y": "test", "z": 0.5} @@ -541,7 +532,7 @@ def test_alternate_prompt_strategy_mixed_parameter_types(self, mock_llm_factory) node_name="test_node", user_input="test input", handler_func=handler_func, - validated_params=validated_params + validated_params=validated_params, ) assert result is not None @@ -606,39 +597,35 @@ def test_create_retry_strategy(self): def test_create_fallback_strategy(self): """Test creating a fallback strategy via factory function.""" fallback_handler = Mock() - strategy = create_fallback_strategy( - fallback_handler, "custom_fallback") + strategy = create_fallback_strategy(fallback_handler, "custom_fallback") assert isinstance(strategy, FallbackToAnotherNodeStrategy) assert strategy.fallback_handler == fallback_handler assert strategy.fallback_name == "custom_fallback" - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_create_self_reflect_strategy(self, mock_llm_factory): """Test creating a self-reflect strategy via factory function.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} strategy = create_self_reflect_strategy(llm_config, max_reflections=3) assert isinstance(strategy, SelfReflectStrategy) assert strategy.llm_config == llm_config assert strategy.max_reflections == 3 - @patch('intent_kit.services.llm_factory.LLMFactory') + @patch("intent_kit.services.llm_factory.LLMFactory") def test_create_consensus_vote_strategy(self, mock_llm_factory): """Test creating a consensus vote strategy via factory function.""" llm_configs = [ {"provider": "openai", "model": "gpt-4", "api_key": "test-key"}, - {"provider": "google", "model": "gemini", "api_key": "test-key"} + {"provider": "google", "model": "gemini", "api_key": "test-key"}, ] - strategy = create_consensus_vote_strategy( - llm_configs, vote_threshold=0.7) + strategy = create_consensus_vote_strategy(llm_configs, vote_threshold=0.7) assert isinstance(strategy, ConsensusVoteStrategy) assert strategy.llm_configs == llm_configs assert strategy.vote_threshold == 0.7 def test_create_alternate_prompt_strategy(self): """Test creating an alternate prompt strategy via factory function.""" - llm_config = {"provider": "openai", - "model": "gpt-4", "api_key": "test-key"} + llm_config = {"provider": "openai", "model": "gpt-4", "api_key": "test-key"} custom_prompts = ["Custom prompt 1", "Custom prompt 2"] strategy = create_alternate_prompt_strategy(llm_config, custom_prompts) assert isinstance(strategy, RetryWithAlternatePromptStrategy) @@ -675,9 +662,13 @@ def test_list_remediation_strategies(self): def test_reflection_response_valid_json(): - with patch('intent_kit.services.llm_factory.LLMFactory.create_client') as mock_create_client: + with patch( + "intent_kit.services.llm_factory.LLMFactory.create_client" + ) as mock_create_client: mock_client = MagicMock() - mock_client.generate.return_value = '{"analysis": "Looks good", "confidence": 0.9}' + mock_client.generate.return_value = ( + '{"analysis": "Looks good", "confidence": 0.9}' + ) mock_create_client.return_value = mock_client reflection_response = '{"analysis": "Looks good", "confidence": 0.9}' data = extract_json_from_text(reflection_response) @@ -685,20 +676,24 @@ def test_reflection_response_valid_json(): def test_reflection_response_malformed(): - with patch('intent_kit.services.llm_factory.LLMFactory.create_client') as mock_create_client: + with patch( + "intent_kit.services.llm_factory.LLMFactory.create_client" + ) as mock_create_client: mock_client = MagicMock() - mock_client.generate.return_value = 'analysis: Looks good, confidence: 0.9' + mock_client.generate.return_value = "analysis: Looks good, confidence: 0.9" mock_create_client.return_value = mock_client - reflection_response = 'analysis: Looks good, confidence: 0.9' + reflection_response = "analysis: Looks good, confidence: 0.9" data = extract_json_from_text(reflection_response) assert data == {"analysis": "Looks good", "confidence": 0.9} def test_vote_response_empty(): - with patch('intent_kit.services.llm_factory.LLMFactory.create_client') as mock_create_client: + with patch( + "intent_kit.services.llm_factory.LLMFactory.create_client" + ) as mock_create_client: mock_client = MagicMock() - mock_client.generate.return_value = '' + mock_client.generate.return_value = "" mock_create_client.return_value = mock_client - vote_response = '' + vote_response = "" data = extract_json_from_text(vote_response) assert data is None or data == {} diff --git a/tests/test_text_utils.py b/tests/test_text_utils.py index 8c191ef..cf41f0c 100644 --- a/tests/test_text_utils.py +++ b/tests/test_text_utils.py @@ -10,7 +10,7 @@ is_deserializable_json, clean_for_deserialization, extract_structured_data, - validate_json_structure + validate_json_structure, ) import json @@ -26,7 +26,7 @@ def test_extract_json_from_text_valid_json(self): def test_extract_json_from_text_invalid_json(self): """Test extracting invalid JSON from text.""" - text = 'Here is the response: {key: value, number: 42}' + text = "Here is the response: {key: value, number: 42}" result = extract_json_from_text(text) self.assertEqual(result, {"key": "value", "number": 42}) @@ -38,7 +38,7 @@ def test_extract_json_from_text_with_code_blocks(self): def test_extract_json_from_text_no_json(self): """Test extracting JSON when none exists.""" - text = 'This is just plain text' + text = "This is just plain text" result = extract_json_from_text(text) self.assertIsNone(result) @@ -50,13 +50,13 @@ def test_extract_json_array_from_text_valid_array(self): def test_extract_json_array_from_text_manual_extraction(self): """Test manual extraction of array-like data.""" - text = '1. First item\n2. Second item\n3. Third item' + text = "1. First item\n2. Second item\n3. Third item" result = extract_json_array_from_text(text) self.assertEqual(result, ["First item", "Second item", "Third item"]) def test_extract_json_array_from_text_dash_items(self): """Test extracting dash-separated items.""" - text = '- Item one\n- Item two\n- Item three' + text = "- Item one\n- Item two\n- Item three" result = extract_json_array_from_text(text) self.assertEqual(result, ["Item one", "Item two", "Item three"]) @@ -68,13 +68,13 @@ def test_extract_key_value_pairs_quoted_keys(self): def test_extract_key_value_pairs_unquoted_keys(self): """Test extracting key-value pairs with unquoted keys.""" - text = 'name: John, age: 30, active: true' + text = "name: John, age: 30, active: true" result = extract_key_value_pairs(text) self.assertEqual(result, {"name": "John", "age": 30, "active": True}) def test_extract_key_value_pairs_equals_sign(self): """Test extracting key-value pairs with equals sign.""" - text = 'name = John, age = 30, active = true' + text = "name = John, age = 30, active = true" result = extract_key_value_pairs(text) self.assertEqual(result, {"name": "John", "age": 30, "active": True}) @@ -86,7 +86,7 @@ def test_is_deserializable_json_valid(self): def test_is_deserializable_json_invalid(self): """Test checking invalid JSON.""" - text = '{key: value}' + text = "{key: value}" result = is_deserializable_json(text) self.assertFalse(result) @@ -130,21 +130,21 @@ def test_extract_structured_data_json_array(self): def test_extract_structured_data_manual_object(self): """Test extracting structured data with manual object extraction.""" - text = 'key: value, number: 42' + text = "key: value, number: 42" data, method = extract_structured_data(text, "dict") self.assertEqual(data, {"key": "value", "number": 42}) self.assertEqual(method, "manual_object") def test_extract_structured_data_manual_array(self): """Test extracting structured data with manual array extraction.""" - text = '1. Item one\n2. Item two' + text = "1. Item one\n2. Item two" data, method = extract_structured_data(text, "list") self.assertEqual(data, ["Item one", "Item two"]) self.assertEqual(method, "manual_array") def test_extract_structured_data_string(self): """Test extracting structured data as string.""" - text = 'This is a simple string' + text = "This is a simple string" data, method = extract_structured_data(text, "string") self.assertEqual(data, "This is a simple string") self.assertEqual(method, "string") @@ -220,28 +220,28 @@ def test_edge_cases_non_string_input(self): self.assertEqual(result, {}) def test_extract_json_from_text_json_block(self): - text = '''Here is a block: + text = """Here is a block: ```json {"foo": "bar", "num": 123} ``` - ''' + """ result = extract_json_from_text(text) self.assertEqual(result, {"foo": "bar", "num": 123}) def test_extract_json_array_from_text_json_block(self): - text = '''Some output: + text = """Some output: ```json ["a", "b", "c"] ``` - ''' + """ result = extract_json_array_from_text(text) self.assertEqual(result, ["a", "b", "c"]) def test_extract_json_from_text_json_block_malformed(self): - text = '''```json\n{"foo": "bar", "num": }```''' + text = """```json\n{"foo": "bar", "num": }```""" result = extract_json_from_text(text) - self.assertEqual(result, {'foo': 'bar', 'num': ''}) + self.assertEqual(result, {"foo": "bar", "num": ""}) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index c9e53c6..722b855 100644 --- a/uv.lock +++ b/uv.lock @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + [[package]] name = "asttokens" version = "3.0.0" @@ -56,6 +65,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "backrefs" +version = "5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[package.optional-dependencies] +jupyter = [ + { name = "ipython" }, + { name = "tokenize-rt" }, +] + +[[package]] +name = "build" +version = "1.2.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -74,6 +163,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -122,6 +265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -131,6 +286,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210, upload-time = "2024-03-12T16:53:41.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180, upload-time = "2024-03-12T16:53:39.226Z" }, +] + [[package]] name = "coverage" version = "7.9.2" @@ -190,6 +357,60 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444, upload-time = "2025-04-10T19:46:10.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/e8/57fe0c86915671fd6a3d2d8746e40485fd55e8d9e682388fbb3a3d42b86f/debugpy-1.8.14-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:1b2ac8c13b2645e0b1eaf30e816404990fbdb168e193322be8f545e8c01644a9", size = 2175064, upload-time = "2025-04-10T19:46:19.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/2b2fd1b1c9569c6764ccdb650a6f752e4ac31be465049563c9eb127a8487/debugpy-1.8.14-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf431c343a99384ac7eab2f763980724834f933a271e90496944195318c619e2", size = 3132359, upload-time = "2025-04-10T19:46:21.192Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ee/b825c87ed06256ee2a7ed8bab8fb3bb5851293bf9465409fdffc6261c426/debugpy-1.8.14-cp311-cp311-win32.whl", hash = "sha256:c99295c76161ad8d507b413cd33422d7c542889fbb73035889420ac1fad354f2", size = 5133269, upload-time = "2025-04-10T19:46:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a6/6c70cd15afa43d37839d60f324213843174c1d1e6bb616bd89f7c1341bac/debugpy-1.8.14-cp311-cp311-win_amd64.whl", hash = "sha256:7816acea4a46d7e4e50ad8d09d963a680ecc814ae31cdef3622eb05ccacf7b01", size = 5158156, upload-time = "2025-04-10T19:46:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/ac2df0eda4898f29c46eb6713a5148e6f8b2b389c8ec9e425a4a1d67bf07/debugpy-1.8.14-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", size = 2501268, upload-time = "2025-04-10T19:46:26.044Z" }, + { url = "https://files.pythonhosted.org/packages/10/53/0a0cb5d79dd9f7039169f8bf94a144ad3efa52cc519940b3b7dde23bcb89/debugpy-1.8.14-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826", size = 4221077, upload-time = "2025-04-10T19:46:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d5/84e01821f362327bf4828728aa31e907a2eca7c78cd7c6ec062780d249f8/debugpy-1.8.14-cp312-cp312-win32.whl", hash = "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", size = 5255127, upload-time = "2025-04-10T19:46:29.467Z" }, + { url = "https://files.pythonhosted.org/packages/33/16/1ed929d812c758295cac7f9cf3dab5c73439c83d9091f2d91871e648093e/debugpy-1.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", size = 5297249, upload-time = "2025-04-10T19:46:31.538Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/395c792b243f2367d84202dc33689aa3d910fb9826a7491ba20fc9e261f5/debugpy-1.8.14-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", size = 2485676, upload-time = "2025-04-10T19:46:32.96Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/6f2ee3f991327ad9e4c2f8b82611a467052a0fb0e247390192580e89f7ff/debugpy-1.8.14-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", size = 4217514, upload-time = "2025-04-10T19:46:34.336Z" }, + { url = "https://files.pythonhosted.org/packages/79/28/b9d146f8f2dc535c236ee09ad3e5ac899adb39d7a19b49f03ac95d216beb/debugpy-1.8.14-cp313-cp313-win32.whl", hash = "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", size = 5254756, upload-time = "2025-04-10T19:46:36.199Z" }, + { url = "https://files.pythonhosted.org/packages/e0/62/a7b4a57013eac4ccaef6977966e6bec5c63906dd25a86e35f155952e29a1/debugpy-1.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", size = 5297119, upload-time = "2025-04-10T19:46:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230, upload-time = "2025-04-10T19:46:54.077Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -199,6 +420,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -208,6 +438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -217,6 +456,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, ] +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-auth" version = "2.40.3" @@ -250,6 +510,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/5c/659c2b992d631a873ae8fed612ce92af423fdc5f7d541dec7ce8f4b1789e/google_genai-1.21.1-py3-none-any.whl", hash = "sha256:fa6fa5311f9a757ce65cd528a938a0f309bb3032516015bf5b3022e63b2fc46b", size = 206388, upload-time = "2025-06-19T14:09:19.016Z" }, ] +[[package]] +name = "griffe" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -287,6 +559,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -296,6 +589,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -321,15 +626,28 @@ openai = [ [package.dev-dependencies] dev = [ { name = "anthropic" }, + { name = "black", extra = ["jupyter"] }, + { name = "build" }, { name = "coverage" }, { name = "google-genai" }, + { name = "ipykernel" }, + { name = "mkdocs" }, + { name = "mkdocs-literate-nav" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mypy" }, { name = "ollama" }, { name = "openai" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "ruff" }, { name = "tqdm" }, + { name = "twine" }, + { name = "types-networkx" }, + { name = "types-pyyaml" }, ] viz = [ { name = "networkx" }, @@ -348,21 +666,59 @@ provides-extras = ["openai"] [package.metadata.requires-dev] dev = [ { name = "anthropic", specifier = ">=0.54.0" }, + { name = "black", specifier = ">=24.4.2" }, + { name = "black", extras = ["jupyter"], specifier = ">=24.4.2" }, + { name = "build", specifier = ">=1.2.1" }, { name = "coverage", specifier = ">=7.0" }, { name = "google-genai", specifier = ">=0.1.0" }, + { name = "ipykernel", specifier = ">=6.0.0" }, + { name = "mkdocs", specifier = ">=1.5.0" }, + { name = "mkdocs-literate-nav", specifier = ">=0.6.0" }, + { name = "mkdocs-material", specifier = ">=9.5.17" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.24.0" }, + { name = "mypy", specifier = ">=1.10.0" }, { name = "ollama", specifier = ">=0.1.0" }, { name = "openai", specifier = ">=1.0.0" }, + { name = "pre-commit", specifier = ">=3.6.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml" }, + { name = "ruff", specifier = ">=0.4.7" }, { name = "tqdm" }, + { name = "twine", specifier = ">=5.1.0" }, + { name = "types-networkx", specifier = ">=3.5.0.20250701" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, ] viz = [ { name = "networkx", specifier = ">=3.5" }, { name = "pyvis", specifier = ">=0.3.2" }, ] +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367, upload-time = "2024-07-01T14:07:22.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173, upload-time = "2024-07-01T14:07:19.603Z" }, +] + [[package]] name = "ipython" version = "9.3.0" @@ -397,6 +753,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1c/831faaaa0f090b711c355c6d8b2abf277c72133aab472b6932b03322294c/jaraco_functools-4.2.1.tar.gz", hash = "sha256:be634abfccabce56fa3053f8c7ebe37b682683a4ee7793670ced17bab0087353", size = 19661, upload-time = "2025-06-21T19:22:03.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/fd/179a20f832824514df39a90bb0e5372b314fea99f217f5ab942b10a8a4e8/jaraco_functools-4.2.1-py3-none-any.whl", hash = "sha256:590486285803805f4b1f99c60ca9e94ed348d4added84b74c7a12885561e524e", size = 10349, upload-time = "2025-06-21T19:22:02.039Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -409,6 +801,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -490,6 +891,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, ] +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880, upload-time = "2025-05-27T07:38:15.137Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "markdown" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -550,6 +1020,214 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.6.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload-time = "2025-07-01T10:14:15.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload-time = "2025-07-01T10:14:13.18Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686, upload-time = "2025-03-31T08:33:11.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075, upload-time = "2025-03-31T08:33:09.661Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.16.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ed/b886f8c714fd7cccc39b79646b627dbea84cd95c46be43459ef46852caf0/mkdocstrings_python-1.16.12.tar.gz", hash = "sha256:9b9eaa066e0024342d433e332a41095c4e429937024945fea511afe58f63175d", size = 206065, upload-time = "2025-06-03T12:52:49.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/dd/a24ee3de56954bfafb6ede7cd63c2413bb842cc48eb45e41c43a05a33074/mkdocstrings_python-1.16.12-py3-none-any.whl", hash = "sha256:22ded3a63b3d823d57457a70ff9860d5a4de9e8b1e482876fc9baabaf6f5f374", size = 124287, upload-time = "2025-06-03T12:52:47.819Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "networkx" version = "3.5" @@ -559,6 +1237,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, ] +[[package]] +name = "nh3" +version = "0.2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581, upload-time = "2025-02-25T13:38:44.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678, upload-time = "2025-02-25T13:37:56.063Z" }, + { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774, upload-time = "2025-02-25T13:37:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012, upload-time = "2025-02-25T13:38:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619, upload-time = "2025-02-25T13:38:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384, upload-time = "2025-02-25T13:38:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908, upload-time = "2025-02-25T13:38:06.693Z" }, + { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180, upload-time = "2025-02-25T13:38:10.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747, upload-time = "2025-02-25T13:38:12.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908, upload-time = "2025-02-25T13:38:14.059Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133, upload-time = "2025-02-25T13:38:16.601Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328, upload-time = "2025-02-25T13:38:18.972Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020, upload-time = "2025-02-25T13:38:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878, upload-time = "2025-02-25T13:38:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460, upload-time = "2025-02-25T13:38:25.951Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369, upload-time = "2025-02-25T13:38:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036, upload-time = "2025-02-25T13:38:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712, upload-time = "2025-02-25T13:38:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559, upload-time = "2025-02-25T13:38:35.204Z" }, + { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591, upload-time = "2025-02-25T13:38:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670, upload-time = "2025-02-25T13:38:38.696Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093, upload-time = "2025-02-25T13:38:40.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623, upload-time = "2025-02-25T13:38:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283, upload-time = "2025-02-25T13:38:43.355Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b", size = 20390372, upload-time = "2025-06-21T12:28:33.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/c7/87c64d7ab426156530676000c94784ef55676df2f13b2796f97722464124/numpy-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ea9e48336a402551f52cd8f593343699003d2353daa4b72ce8d34f66b722070", size = 21199346, upload-time = "2025-06-21T11:47:47.57Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/0966c2f44beeac12af8d836e5b5f826a407cf34c45cb73ddcdfce9f5960b/numpy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccb7336eaf0e77c1635b232c141846493a588ec9ea777a7c24d7166bb8533ae", size = 14361143, upload-time = "2025-06-21T11:48:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/7d/31/6e35a247acb1bfc19226791dfc7d4c30002cd4e620e11e58b0ddf836fe52/numpy-2.3.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bb3a4a61e1d327e035275d2a993c96fa786e4913aa089843e6a2d9dd205c66a", size = 5378989, upload-time = "2025-06-21T11:48:19.998Z" }, + { url = "https://files.pythonhosted.org/packages/b0/25/93b621219bb6f5a2d4e713a824522c69ab1f06a57cd571cda70e2e31af44/numpy-2.3.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:e344eb79dab01f1e838ebb67aab09965fb271d6da6b00adda26328ac27d4a66e", size = 6912890, upload-time = "2025-06-21T11:48:31.376Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/6b06ed98d11fb32e27fb59468b42383f3877146d3ee639f733776b6ac596/numpy-2.3.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:467db865b392168ceb1ef1ffa6f5a86e62468c43e0cfb4ab6da667ede10e58db", size = 14569032, upload-time = "2025-06-21T11:48:52.563Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/9bec03675192077467a9c7c2bdd1f2e922bd01d3a69b15c3a0fdcd8548f6/numpy-2.3.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:afed2ce4a84f6b0fc6c1ce734ff368cbf5a5e24e8954a338f3bdffa0718adffb", size = 16930354, upload-time = "2025-06-21T11:49:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/5756a00cabcf50a3f527a0c968b2b4881c62b1379223931853114fa04cda/numpy-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0025048b3c1557a20bc80d06fdeb8cc7fc193721484cca82b2cfa072fec71a93", size = 15879605, upload-time = "2025-06-21T11:49:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/ff/86/a471f65f0a86f1ca62dcc90b9fa46174dd48f50214e5446bc16a775646c5/numpy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5ee121b60aa509679b682819c602579e1df14a5b07fe95671c8849aad8f2115", size = 18666994, upload-time = "2025-06-21T11:50:08.516Z" }, + { url = "https://files.pythonhosted.org/packages/43/a6/482a53e469b32be6500aaf61cfafd1de7a0b0d484babf679209c3298852e/numpy-2.3.1-cp311-cp311-win32.whl", hash = "sha256:a8b740f5579ae4585831b3cf0e3b0425c667274f82a484866d2adf9570539369", size = 6603672, upload-time = "2025-06-21T11:50:19.584Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/bb613f4122c310a13ec67585c70e14b03bfc7ebabd24f4d5138b97371d7c/numpy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4580adadc53311b163444f877e0789f1c8861e2698f6b2a4ca852fda154f3ff", size = 13024015, upload-time = "2025-06-21T11:50:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/2d842825af9a0c041aca246dc92eb725e1bc5e1c9ac89712625db0c4e11c/numpy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:ec0bdafa906f95adc9a0c6f26a4871fa753f25caaa0e032578a30457bff0af6a", size = 10456989, upload-time = "2025-06-21T11:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d", size = 20896664, upload-time = "2025-06-21T12:15:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29", size = 14131078, upload-time = "2025-06-21T12:15:52.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc", size = 5112554, upload-time = "2025-06-21T12:16:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943", size = 6646560, upload-time = "2025-06-21T12:16:11.895Z" }, + { url = "https://files.pythonhosted.org/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25", size = 14260638, upload-time = "2025-06-21T12:16:32.611Z" }, + { url = "https://files.pythonhosted.org/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660", size = 16632729, upload-time = "2025-06-21T12:16:57.439Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952", size = 15565330, upload-time = "2025-06-21T12:17:20.638Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77", size = 18361734, upload-time = "2025-06-21T12:17:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab", size = 6320411, upload-time = "2025-06-21T12:17:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76", size = 12734973, upload-time = "2025-06-21T12:18:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30", size = 10191491, upload-time = "2025-06-21T12:18:33.585Z" }, + { url = "https://files.pythonhosted.org/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8", size = 20889381, upload-time = "2025-06-21T12:19:04.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e", size = 14152726, upload-time = "2025-06-21T12:19:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0", size = 5105145, upload-time = "2025-06-21T12:19:34.782Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d", size = 6639409, upload-time = "2025-06-21T12:19:45.228Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1", size = 14257630, upload-time = "2025-06-21T12:20:06.544Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1", size = 16627546, upload-time = "2025-06-21T12:20:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0", size = 15562538, upload-time = "2025-06-21T12:20:54.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8", size = 18360327, upload-time = "2025-06-21T12:21:21.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8", size = 6312330, upload-time = "2025-06-21T12:25:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42", size = 12731565, upload-time = "2025-06-21T12:25:26.444Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e", size = 10190262, upload-time = "2025-06-21T12:25:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8", size = 20987593, upload-time = "2025-06-21T12:21:51.664Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb", size = 14300523, upload-time = "2025-06-21T12:22:13.583Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee", size = 5227993, upload-time = "2025-06-21T12:22:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992", size = 6736652, upload-time = "2025-06-21T12:22:33.629Z" }, + { url = "https://files.pythonhosted.org/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c", size = 14331561, upload-time = "2025-06-21T12:22:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48", size = 16693349, upload-time = "2025-06-21T12:23:20.53Z" }, + { url = "https://files.pythonhosted.org/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee", size = 15642053, upload-time = "2025-06-21T12:23:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280", size = 18434184, upload-time = "2025-06-21T12:24:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e", size = 6440678, upload-time = "2025-06-21T12:24:21.596Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc", size = 12870697, upload-time = "2025-06-21T12:24:40.644Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244", size = 10260376, upload-time = "2025-06-21T12:24:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/facc13b9b42ddca30498fc51f7f73c3d0f2be179943a4b4da8686e259740/numpy-2.3.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad506d4b09e684394c42c966ec1527f6ebc25da7f4da4b1b056606ffe446b8a3", size = 21070637, upload-time = "2025-06-21T12:26:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/41b705d9dbae04649b529fc9bd3387664c3281c7cd78b404a4efe73dcc45/numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ebb8603d45bc86bbd5edb0d63e52c5fd9e7945d3a503b77e486bd88dde67a19b", size = 5304087, upload-time = "2025-06-21T12:26:22.294Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/fe3ac1902bff7a4934a22d49e1c9d71a623204d654d4cc43c6e8fe337fcb/numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:15aa4c392ac396e2ad3d0a2680c0f0dee420f9fed14eef09bdb9450ee6dcb7b7", size = 6817588, upload-time = "2025-06-21T12:26:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ee/89bedf69c36ace1ac8f59e97811c1f5031e179a37e4821c3a230bf750142/numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c6e0bf9d1a2f50d2b65a7cf56db37c095af17b59f6c132396f7c6d5dd76484df", size = 14399010, upload-time = "2025-06-21T12:26:54.086Z" }, + { url = "https://files.pythonhosted.org/packages/15/08/e00e7070ede29b2b176165eba18d6f9784d5349be3c0c1218338e79c27fd/numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eabd7e8740d494ce2b4ea0ff05afa1b7b291e978c0ae075487c51e8bd93c0c68", size = 16752042, upload-time = "2025-06-21T12:27:19.018Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/1c6b515a83d5564b1698a61efa245727c8feecf308f4091f565988519d20/numpy-2.3.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e610832418a2bc09d974cc9fecebfa51e9532d6190223bc5ef6a7402ebf3b5cb", size = 12927246, upload-time = "2025-06-21T12:27:38.618Z" }, +] + [[package]] name = "ollama" version = "0.5.1" @@ -600,6 +1376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "parso" version = "0.8.4" @@ -609,6 +1394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -621,6 +1415,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -630,6 +1433,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.51" @@ -642,6 +1461,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -681,6 +1515,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + [[package]] name = "pydantic" version = "2.11.3" @@ -770,6 +1613,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -800,6 +1665,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -823,6 +1700,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" }, ] +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -858,6 +1760,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/06/50a4e9648b3e8b992bef8eb632e457307553a89d294103213cfd47b3da69/pyzmq-27.0.0.tar.gz", hash = "sha256:b1f08eeb9ce1510e6939b6e5dcd46a17765e2333daae78ecf4606808442e52cf", size = 280478, upload-time = "2025-06-13T14:09:07.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/df/84c630654106d9bd9339cdb564aa941ed41b023a0264251d6743766bb50e/pyzmq-27.0.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:21457825249b2a53834fa969c69713f8b5a79583689387a5e7aed880963ac564", size = 1332718, upload-time = "2025-06-13T14:07:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8e/f6a5461a07654d9840d256476434ae0ff08340bba562a455f231969772cb/pyzmq-27.0.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1958947983fef513e6e98eff9cb487b60bf14f588dc0e6bf35fa13751d2c8251", size = 908248, upload-time = "2025-06-13T14:07:18.033Z" }, + { url = "https://files.pythonhosted.org/packages/7c/93/82863e8d695a9a3ae424b63662733ae204a295a2627d52af2f62c2cd8af9/pyzmq-27.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0dc628b5493f9a8cd9844b8bee9732ef587ab00002157c9329e4fc0ef4d3afa", size = 668647, upload-time = "2025-06-13T14:07:19.378Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/15278769b348121eacdbfcbd8c4d40f1102f32fa6af5be1ffc032ed684be/pyzmq-27.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7bbe9e1ed2c8d3da736a15694d87c12493e54cc9dc9790796f0321794bbc91f", size = 856600, upload-time = "2025-06-13T14:07:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/d4/af/1c469b3d479bd095edb28e27f12eee10b8f00b356acbefa6aeb14dd295d1/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc1091f59143b471d19eb64f54bae4f54bcf2a466ffb66fe45d94d8d734eb495", size = 1657748, upload-time = "2025-06-13T14:07:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f4/17f965d0ee6380b1d6326da842a50e4b8b9699745161207945f3745e8cb5/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7011ade88c8e535cf140f8d1a59428676fbbce7c6e54fefce58bf117aefb6667", size = 2034311, upload-time = "2025-06-13T14:07:23.966Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6e/7c391d81fa3149fd759de45d298003de6cfab343fb03e92c099821c448db/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c386339d7e3f064213aede5d03d054b237937fbca6dd2197ac8cf3b25a6b14e", size = 1893630, upload-time = "2025-06-13T14:07:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e0/eaffe7a86f60e556399e224229e7769b717f72fec0706b70ab2c03aa04cb/pyzmq-27.0.0-cp311-cp311-win32.whl", hash = "sha256:0546a720c1f407b2172cb04b6b094a78773491497e3644863cf5c96c42df8cff", size = 567706, upload-time = "2025-06-13T14:07:27.595Z" }, + { url = "https://files.pythonhosted.org/packages/c9/05/89354a8cffdcce6e547d48adaaf7be17007fc75572123ff4ca90a4ca04fc/pyzmq-27.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:15f39d50bd6c9091c67315ceb878a4f531957b121d2a05ebd077eb35ddc5efed", size = 630322, upload-time = "2025-06-13T14:07:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/4ab976d5e1e63976719389cc4f3bfd248a7f5f2bb2ebe727542363c61b5f/pyzmq-27.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c5817641eebb391a2268c27fecd4162448e03538387093cdbd8bf3510c316b38", size = 558435, upload-time = "2025-06-13T14:07:30.256Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/9ad68f55b8834ede477842214feba6a4c786d936c022a67625497aacf61d/pyzmq-27.0.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:cbabc59dcfaac66655c040dfcb8118f133fb5dde185e5fc152628354c1598e52", size = 1305438, upload-time = "2025-06-13T14:07:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/26aa0f98665a22bc90ebe12dced1de5f3eaca05363b717f6fb229b3421b3/pyzmq-27.0.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cb0ac5179cba4b2f94f1aa208fbb77b62c4c9bf24dd446278b8b602cf85fcda3", size = 895095, upload-time = "2025-06-13T14:07:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/c57e7ab216ecd8aa4cc7e3b83b06cc4e9cf45c87b0afc095f10cd5ce87c1/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53a48f0228eab6cbf69fde3aa3c03cbe04e50e623ef92ae395fce47ef8a76152", size = 651826, upload-time = "2025-06-13T14:07:34.831Z" }, + { url = "https://files.pythonhosted.org/packages/69/9a/9ea7e230feda9400fb0ae0d61d7d6ddda635e718d941c44eeab22a179d34/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111db5f395e09f7e775f759d598f43cb815fc58e0147623c4816486e1a39dc22", size = 839750, upload-time = "2025-06-13T14:07:36.553Z" }, + { url = "https://files.pythonhosted.org/packages/08/66/4cebfbe71f3dfbd417011daca267539f62ed0fbc68105357b68bbb1a25b7/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c8878011653dcdc27cc2c57e04ff96f0471e797f5c19ac3d7813a245bcb24371", size = 1641357, upload-time = "2025-06-13T14:07:38.21Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f6/b0f62578c08d2471c791287149cb8c2aaea414ae98c6e995c7dbe008adfb/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0ed2c1f335ba55b5fdc964622254917d6b782311c50e138863eda409fbb3b6d", size = 2020281, upload-time = "2025-06-13T14:07:39.599Z" }, + { url = "https://files.pythonhosted.org/packages/37/b9/4f670b15c7498495da9159edc374ec09c88a86d9cd5a47d892f69df23450/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e918d70862d4cfd4b1c187310015646a14e1f5917922ab45b29f28f345eeb6be", size = 1877110, upload-time = "2025-06-13T14:07:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/66/31/9dee25c226295b740609f0d46db2fe972b23b6f5cf786360980524a3ba92/pyzmq-27.0.0-cp312-abi3-win32.whl", hash = "sha256:88b4e43cab04c3c0f0d55df3b1eef62df2b629a1a369b5289a58f6fa8b07c4f4", size = 559297, upload-time = "2025-06-13T14:07:42.533Z" }, + { url = "https://files.pythonhosted.org/packages/9b/12/52da5509800f7ff2d287b2f2b4e636e7ea0f001181cba6964ff6c1537778/pyzmq-27.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:dce4199bf5f648a902ce37e7b3afa286f305cd2ef7a8b6ec907470ccb6c8b371", size = 619203, upload-time = "2025-06-13T14:07:43.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/6d/7f2e53b19d1edb1eb4f09ec7c3a1f945ca0aac272099eab757d15699202b/pyzmq-27.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:56e46bbb85d52c1072b3f809cc1ce77251d560bc036d3a312b96db1afe76db2e", size = 551927, upload-time = "2025-06-13T14:07:45.51Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/876b27c4ff777db4ceba1c69ea90d3c825bb4f8d5e7cd987ce5802e33c55/pyzmq-27.0.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c36ad534c0c29b4afa088dc53543c525b23c0797e01b69fef59b1a9c0e38b688", size = 1340826, upload-time = "2025-06-13T14:07:46.881Z" }, + { url = "https://files.pythonhosted.org/packages/43/69/58ef8f4f59d3bcd505260c73bee87b008850f45edca40ddaba54273c35f4/pyzmq-27.0.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67855c14173aec36395d7777aaba3cc527b393821f30143fd20b98e1ff31fd38", size = 897283, upload-time = "2025-06-13T14:07:49.562Z" }, + { url = "https://files.pythonhosted.org/packages/43/15/93a0d0396700a60475ad3c5d42c5f1c308d3570bc94626b86c71ef9953e0/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8617c7d43cd8ccdb62aebe984bfed77ca8f036e6c3e46dd3dddda64b10f0ab7a", size = 660567, upload-time = "2025-06-13T14:07:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b3/fe055513e498ca32f64509abae19b9c9eb4d7c829e02bd8997dd51b029eb/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:67bfbcbd0a04c575e8103a6061d03e393d9f80ffdb9beb3189261e9e9bc5d5e9", size = 847681, upload-time = "2025-06-13T14:07:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4f/ff15300b00b5b602191f3df06bbc8dd4164e805fdd65bb77ffbb9c5facdc/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5cd11d46d7b7e5958121b3eaf4cd8638eff3a720ec527692132f05a57f14341d", size = 1650148, upload-time = "2025-06-13T14:07:54.178Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/84bdfff2a224a6f26a24249a342e5906993c50b0761e311e81b39aef52a7/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b801c2e40c5aa6072c2f4876de8dccd100af6d9918d4d0d7aa54a1d982fd4f44", size = 2023768, upload-time = "2025-06-13T14:07:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/dc2db178c26a42228c5ac94a9cc595030458aa64c8d796a7727947afbf55/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20d5cb29e8c5f76a127c75b6e7a77e846bc4b655c373baa098c26a61b7ecd0ef", size = 1885199, upload-time = "2025-06-13T14:07:57.166Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/dae7b06a1f8cdee5d8e7a63d99c5d129c401acc40410bef2cbf42025e26f/pyzmq-27.0.0-cp313-cp313t-win32.whl", hash = "sha256:a20528da85c7ac7a19b7384e8c3f8fa707841fd85afc4ed56eda59d93e3d98ad", size = 575439, upload-time = "2025-06-13T14:07:58.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bc/1709dc55f0970cf4cb8259e435e6773f9946f41a045c2cb90e870b7072da/pyzmq-27.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d8229f2efece6a660ee211d74d91dbc2a76b95544d46c74c615e491900dc107f", size = 639933, upload-time = "2025-06-13T14:08:00.777Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/92394373b8dbc1edc9d53c951e8d3989d518185174ee54492ec27711779d/pyzmq-27.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd1dc59763effd1576f8368047c9c31468fce0af89d76b5067641137506792ae", size = 835948, upload-time = "2025-06-13T14:08:43.516Z" }, + { url = "https://files.pythonhosted.org/packages/56/f3/4dc38d75d9995bfc18773df3e41f2a2ca9b740b06f1a15dbf404077e7588/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:60e8cc82d968174650c1860d7b716366caab9973787a1c060cf8043130f7d0f7", size = 799874, upload-time = "2025-06-13T14:08:45.017Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/64af397e0f421453dc68e31d5e0784d554bf39013a2de0872056e96e58af/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14fe7aaac86e4e93ea779a821967360c781d7ac5115b3f1a171ced77065a0174", size = 567400, upload-time = "2025-06-13T14:08:46.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/ec956cbe98809270b59a22891d5758edae147a258e658bf3024a8254c855/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ad0562d4e6abb785be3e4dd68599c41be821b521da38c402bc9ab2a8e7ebc7e", size = 747031, upload-time = "2025-06-13T14:08:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/4a3764a68abc02e2fbb0668d225b6fda5cd39586dd099cee8b2ed6ab0452/pyzmq-27.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9df43a2459cd3a3563404c1456b2c4c69564daa7dbaf15724c09821a3329ce46", size = 544726, upload-time = "2025-06-13T14:08:49.903Z" }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -873,6 +1846,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -885,6 +1892,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" }, + { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" }, + { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" }, + { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" }, + { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" }, + { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -917,6 +1971,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, ] +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -956,6 +2019,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "tornado" +version = "6.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" }, + { url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" }, + { url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" }, + { url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" }, + { url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -977,6 +2059,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "twine" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404, upload-time = "2025-01-21T18:45:26.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, +] + +[[package]] +name = "types-networkx" +version = "3.5.0.20250701" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/f2/c5b6519cc0ac7714119300ce4d72a49e3674bb862cfe137826f12cfd6ec2/types_networkx-3.5.0.20250701.tar.gz", hash = "sha256:a3006f82c30e16bbb093382dac60a4ab803f3fe0afde28be613bcf1d3df261ba", size = 64426, upload-time = "2025-07-01T03:23:39.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/d8dfe506d542276ce88bd772cc831265efaa1fb22323c086e32372e9a9ae/types_networkx-3.5.0.20250701-py3-none-any.whl", hash = "sha256:2e09f50567f32c9e1b5e13997641360681c39f09a055ccc9ec882783a8740106", size = 150453, upload-time = "2025-07-01T03:23:38.593Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0" @@ -1007,6 +2130,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -1057,3 +2221,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]